Skip to main content

orinium_browser/engine/input/
mod.rs

1//! 入力処理とヒットテスト。クリック位置の要素判定を行う。
2
3use std::sync::Arc;
4
5use crate::engine::layouter::types::TextFlowStyle;
6
7use super::layouter::types::{ContainerStyle, InfoNode, NodeKind, TextStyle};
8use super::renderer_model::{AffineTransform, StickyViewport, is_scrollport, sticky_offset};
9use super::ui::PointerEvent;
10use super::ui::custom_node::CustomNode;
11use super::ui::input_text_types::InputTextEvent;
12use ui_layout::{BoxModel, LayoutNode, Position};
13/// ヒットしたノード情報
14#[derive(Clone, Debug)]
15pub struct HitItem<'a> {
16    pub layout: &'a LayoutNode,
17    pub info: &'a InfoNode,
18}
19
20/// ヒットパス(子→親の順)
21pub type HitPath<'a> = Vec<HitItem<'a>>;
22
23/// Returns the innermost custom node on a hit path, if any.
24///
25/// The hit path is ordered child→parent, so the first `Custom` node found is
26/// the deepest node under the pointer.
27pub fn hit_custom_node<'a>(path: &'a HitPath<'a>) -> Option<&'a Arc<dyn CustomNode>> {
28    path.iter().find_map(|hit| match &hit.info.kind {
29        NodeKind::Custom { node, .. } => Some(node),
30        _ => None,
31    })
32}
33
34/// Returns the innermost DOM node id on a hit path, if any.
35///
36/// The hit path is ordered child→parent, so the first node carrying a
37/// [`InfoNode::dom_id`] is the deepest DOM-backed element under the pointer.
38pub fn hit_dom_id(path: &HitPath<'_>) -> Option<u32> {
39    path.iter().find_map(|hit| hit.info.dom_id)
40}
41
42/// Returns the root-to-target DOM node id path for the hit, if any.
43///
44/// The path is derived from the hit path (innermost first) and contains every
45/// DOM-backed node id from root to the hit target, preserving ancestor
46/// propagation for pseudo-classes such as `:hover` and `:active`.
47pub fn hit_dom_path(path: &HitPath<'_>) -> Vec<u32> {
48    let mut out: Vec<u32> = path
49        .iter()
50        .rev()
51        .filter_map(|hit| hit.info.dom_id)
52        .collect();
53    out.dedup();
54    out
55}
56
57fn is_inline(layout: &LayoutNode) -> bool {
58    matches!(layout.layout_box, ui_layout::LayoutBox::InlineBox(_))
59}
60
61fn is_inline_container(layout: &LayoutNode, info: &InfoNode) -> bool {
62    is_inline(layout) && matches!(&info.kind, NodeKind::Container { .. })
63}
64
65fn node_style(info: &InfoNode) -> ContainerStyle {
66    match &info.kind {
67        NodeKind::Container { style, .. }
68        | NodeKind::Custom { style, .. }
69        | NodeKind::Svg { style, .. } => style.clone(),
70        _ => ContainerStyle::default(),
71    }
72}
73
74fn establishes_inline_box_space(layout: &LayoutNode) -> bool {
75    is_inline(layout)
76        && layout.style.display.outer() == Some(ui_layout::OuterDisplay::Inline)
77        && layout.style.display.inner() == Some(ui_layout::InnerDisplay::FlowRoot)
78}
79
80fn child_viewport(
81    layout: &LayoutNode,
82    info: &InfoNode,
83    parent_viewport: StickyViewport,
84    is_root: bool,
85) -> StickyViewport {
86    if is_root || is_scrollport(&info.kind) {
87        let bm = layout.layout_box.iter().next();
88        let (scroll_x, scroll_y) = info.kind.scroll_offsets();
89        StickyViewport {
90            top_left: bm.as_ref().map_or((0.0, 0.0), |b| {
91                (
92                    b.padding_box.x - b.content_box.x - scroll_x,
93                    b.padding_box.y - b.content_box.y + scroll_y,
94                )
95            }),
96            size: if is_root {
97                parent_viewport.size
98            } else {
99                bm.map_or(parent_viewport.size, |b| {
100                    (b.padding_box.width, b.padding_box.height)
101                })
102            },
103        }
104    } else {
105        let bm = layout.layout_box.iter().next();
106        StickyViewport {
107            top_left: bm.map_or(parent_viewport.top_left, |b| {
108                (
109                    parent_viewport.top_left.0 - b.content_box.x,
110                    parent_viewport.top_left.1 - b.content_box.y,
111                )
112            }),
113            size: parent_viewport.size,
114        }
115    }
116}
117
118fn child_containing(layout: &LayoutNode, parent_containing: (f32, f32)) -> (f32, f32) {
119    layout
120        .layout_box
121        .iter()
122        .next()
123        .map_or(parent_containing, |b| {
124            (b.content_box.width, b.content_box.height)
125        })
126}
127
128fn element_transform(
129    style: &ContainerStyle,
130    border_box: &ui_layout::Rect,
131) -> Option<AffineTransform> {
132    let t = style.transform?;
133    if t == AffineTransform::identity() {
134        return None;
135    }
136    let center_x = border_box.width / 2.0;
137    let center_y = border_box.height / 2.0;
138    Some(
139        AffineTransform::translate(center_x, center_y)
140            .then(&t)
141            .then(&AffineTransform::translate(-center_x, -center_y)),
142    )
143}
144
145/// Maps a point from a box's parent-content space into the space where the
146/// box's children/custom content live, mirroring renderer `push_box_model`
147/// transform order (border translate, element CSS transform, content translate,
148/// scroll translate). Returns `None` when the point is outside the box's
149/// interactive padding box in visual space or when a transform is
150/// non-invertible.
151fn map_to_box_local(
152    point: (f32, f32),
153    box_model: &BoxModel,
154    style: &ContainerStyle,
155    scroll: (f32, f32),
156    is_inline_box: bool,
157) -> Option<(f32, f32)> {
158    let mut p = point;
159    if !is_inline_box {
160        // border translate
161        p.0 -= box_model.border_box.x;
162        p.1 -= box_model.border_box.y;
163        // element CSS transform (centered in border-box space)
164        if let Some(t) = element_transform(style, &box_model.border_box) {
165            let inv = t.inverse()?;
166            p = inv.apply(p.0, p.1);
167        }
168    }
169    // padding-box hit area in this (possibly transformed) border-box-local space
170    let rect = if is_inline_box {
171        box_model.padding_box
172    } else {
173        ui_layout::Rect {
174            x: box_model.padding_box.x - box_model.border_box.x,
175            y: box_model.padding_box.y - box_model.border_box.y,
176            width: box_model.padding_box.width,
177            height: box_model.padding_box.height,
178        }
179    };
180    if p.0 < rect.x || p.1 < rect.y || p.0 > rect.x + rect.width || p.1 > rect.y + rect.height {
181        return None;
182    }
183    if is_inline_box {
184        Some(p)
185    } else {
186        // content translate + scroll translate
187        let dx = box_model.content_box.x - box_model.border_box.x;
188        let dy = box_model.content_box.y - box_model.border_box.y;
189        Some((p.0 - dx + scroll.0, p.1 - dy + scroll.1))
190    }
191}
192
193/// Map a point into a custom node's content-box local space for
194/// pointer/popup dispatch, including border/content/scroll/transform but not
195/// padding-box hit rejection (caller decides containment).
196fn map_to_content_local(
197    point: (f32, f32),
198    box_model: &BoxModel,
199    style: &ContainerStyle,
200    scroll: (f32, f32),
201) -> Option<(f32, f32)> {
202    let mut p = point;
203    p.0 -= box_model.border_box.x;
204    p.1 -= box_model.border_box.y;
205    if let Some(t) = element_transform(style, &box_model.border_box) {
206        let inv = t.inverse()?;
207        p = inv.apply(p.0, p.1);
208    }
209    let dx = box_model.content_box.x - box_model.border_box.x;
210    let dy = box_model.content_box.y - box_model.border_box.y;
211    p.0 = p.0 - dx + scroll.0;
212    p.1 = p.1 - dy + scroll.1;
213    Some(p)
214}
215
216/// Converts page-space pointer coordinates into the local content-box space of
217/// the custom node at `path[target]`.
218///
219/// Mirrors renderer traversal/order: fixed scroll cancellation, sticky offsets,
220/// border/content/scroll transforms and CSS element transforms.
221fn local_pointer_coords(path: &HitPath<'_>, target: usize, x: f32, y: f32) -> (f32, f32) {
222    let mut p = (x, y);
223    let mut accumulated_scroll = (0.0f32, 0.0f32);
224    let mut viewport = StickyViewport {
225        top_left: (0.0, 0.0),
226        size: (f32::INFINITY, f32::INFINITY),
227    };
228    let mut containing = (f32::INFINITY, f32::INFINITY);
229    if let Some(root) = path.last() {
230        let size = root
231            .layout
232            .layout_box
233            .iter()
234            .next()
235            .map_or((f32::INFINITY, f32::INFINITY), |b| {
236                (b.content_box.width, b.content_box.height)
237            });
238        let (sx, sy) = root.info.kind.scroll_offsets();
239        viewport = StickyViewport {
240            top_left: (-sx, sy),
241            size,
242        };
243        containing = size;
244    }
245
246    // Walk root -> ... -> target, transforming `p` into each level's space.
247    for step in 0..path.len() {
248        let index = path.len() - 1 - step; // root-first
249        if index < target {
250            break;
251        }
252        let hit = &path[index];
253        let is_root = step == 0;
254        let is_fixed = hit.layout.style.position.kind == Position::Fixed;
255        let is_sticky = hit.layout.style.position.kind == Position::Sticky;
256        let shares_parent_layout =
257            index + 1 < path.len() && std::ptr::eq(hit.layout, path[index + 1].layout);
258        let inline_container = is_inline_container(hit.layout, hit.info);
259
260        if is_fixed {
261            p.0 -= accumulated_scroll.0;
262            p.1 -= accumulated_scroll.1;
263        }
264        if is_sticky
265            && !is_fixed
266            && let Some(bm) = hit.layout.layout_box.iter().next()
267            && let Some(edges) = bm.sticky_edges
268        {
269            let (dx, dy) = sticky_offset(&edges, &bm.border_box, viewport, containing);
270            p.0 -= dx;
271            p.1 -= dy;
272        }
273
274        let own_scroll = hit.info.kind.scroll_offsets();
275        let style = node_style(hit.info);
276
277        if index == target {
278            if !shares_parent_layout
279                && !inline_container
280                && let Some(bm) = hit.layout.layout_box.iter().next()
281                && let Some(lp) = map_to_content_local(p, &bm, &style, own_scroll)
282            {
283                p = lp;
284            }
285            return p;
286        }
287
288        // Descend into this node's child space.
289        if !shares_parent_layout
290            && !inline_container
291            && let Some(bm) = hit.layout.layout_box.iter().next()
292            && let Some(lp) = map_to_content_local(p, &bm, &style, own_scroll)
293        {
294            p = lp;
295        }
296
297        let next_index = index - 1;
298        if next_index < target {
299            break;
300        }
301        let next = &path[next_index];
302        let next_shares = std::ptr::eq(next.layout, hit.layout);
303        if !next_shares && establishes_inline_box_space(hit.layout) && inline_container {
304            // flow-root inline container: renderer translates descendants by box X offset
305            if let Some(bm) = hit.layout.layout_box.iter().next() {
306                p.0 -= bm.content_box.x;
307            }
308        }
309
310        let child_scroll = if is_fixed {
311            own_scroll
312        } else {
313            (
314                accumulated_scroll.0 + own_scroll.0,
315                accumulated_scroll.1 + own_scroll.1,
316            )
317        };
318        viewport = child_viewport(hit.layout, hit.info, viewport, is_root);
319        containing = child_containing(hit.layout, containing);
320        accumulated_scroll = child_scroll;
321        let _ = next;
322    }
323
324    p
325}
326
327/// Dispatches a pointer event to the innermost custom node on the hit path.
328///
329/// Event coordinates are translated from page space into the node's local
330/// content-box space before delivery.
331pub fn dispatch_pointer(path: &HitPath<'_>, event: PointerEvent) -> bool {
332    for (target, hit) in path.iter().enumerate() {
333        if let NodeKind::Custom {
334            node,
335            text_style,
336            text_flow_style,
337            ..
338        } = &hit.info.kind
339        {
340            let (px, py) = match event {
341                PointerEvent::Move { x, y } => (x, y),
342                PointerEvent::Down { x, y } => (x, y),
343                PointerEvent::Up { x, y } => (x, y),
344                PointerEvent::Leave => (0.0, 0.0), // no coordinates for Leave
345            };
346            let (lx, ly) = local_pointer_coords(path, target, px, py);
347            let local_event = match event {
348                PointerEvent::Move { .. } => PointerEvent::Move { x: lx, y: ly },
349                PointerEvent::Down { .. } => PointerEvent::Down { x: lx, y: ly },
350                PointerEvent::Up { .. } => PointerEvent::Up { x: lx, y: ly },
351                PointerEvent::Leave => PointerEvent::Leave,
352            };
353
354            // An open popup intercepts pointer events over it. Its rect is in
355            // the same content-box space as the local coordinates.
356            if let Some(popup) = node.popup(text_style, text_flow_style) {
357                let in_popup = lx >= popup.rect.x
358                    && ly >= popup.rect.y
359                    && lx <= popup.rect.x + popup.rect.width
360                    && ly <= popup.rect.y + popup.rect.height;
361                // Popup events are expressed relative to the popup's own
362                // top-left corner (`popup.rect` origin).
363                let popup_event = match local_event {
364                    PointerEvent::Move { x, y } => PointerEvent::Move {
365                        x: x - popup.rect.x,
366                        y: y - popup.rect.y,
367                    },
368                    PointerEvent::Down { x, y } => PointerEvent::Down {
369                        x: x - popup.rect.x,
370                        y: y - popup.rect.y,
371                    },
372                    PointerEvent::Up { x, y } => PointerEvent::Up {
373                        x: x - popup.rect.x,
374                        y: y - popup.rect.y,
375                    },
376                    PointerEvent::Leave => PointerEvent::Leave,
377                };
378
379                if in_popup {
380                    return node.on_popup_pointer_event(popup_event);
381                }
382            }
383            return node.on_pointer_event(local_event);
384        }
385    }
386    false
387}
388
389/// Dismisses every open popup whose owner is not under the pointer press.
390///
391/// Implements top-layer dismissal: a press whose hit path already contains a
392/// popup's owner is that owner's responsibility (the event is routed to the
393/// popup, or to the owning box which closes it), while every other open popup
394/// is closed. Returns whether any popup was dismissed.
395pub fn dismiss_open_popups(info: &InfoNode, path: &HitPath<'_>, px: f32, py: f32) -> bool {
396    let mut dismissed = false;
397    dismiss_open_popups_inner(info, path, px, py, &mut dismissed);
398    dismissed
399}
400
401fn dismiss_open_popups_inner(
402    info: &InfoNode,
403    path: &HitPath<'_>,
404    px: f32,
405    py: f32,
406    dismissed: &mut bool,
407) {
408    if let NodeKind::Custom { node, .. } = &info.kind
409        && let Some(popup) = node.popup(&TextStyle::default(), &TextFlowStyle::default())
410    {
411        let owner_in_path = path.iter().any(|hit| {
412            matches!(&hit.info.kind, NodeKind::Custom { node: owner, .. } if Arc::ptr_eq(node, owner))
413        });
414        let press_in_popup = if let Some((target, _)) = path.iter().enumerate().find(|(_, hit)| {
415            matches!(&hit.info.kind, NodeKind::Custom { node: owner, .. } if Arc::ptr_eq(node, owner))
416        }) {
417            let (lx, ly) = local_pointer_coords(path, target, px, py);
418            lx >= popup.rect.x
419                && ly >= popup.rect.y
420                && lx <= popup.rect.x + popup.rect.width
421                && ly <= popup.rect.y + popup.rect.height
422        } else {
423            false
424        };
425        if !owner_in_path && !press_in_popup {
426            node.dismiss_popup();
427            *dismissed = true;
428        }
429    }
430    for child in &info.children {
431        dismiss_open_popups_inner(child, path, px, py, dismissed);
432    }
433}
434
435/// Updates the hover state of custom nodes after a pointer move.
436///
437/// Clears hover from the previously hovered node (if different) and sets it on
438/// the node under the pointer. Returns whether the hover target changed.
439pub fn update_hover(path: &HitPath<'_>, previous: Option<&Arc<dyn CustomNode>>) -> bool {
440    let current = hit_custom_node(path);
441    match (previous, current) {
442        (Some(prev), Some(curr)) if Arc::ptr_eq(prev, curr) => false,
443        (Some(prev), _) => {
444            prev.set_hovered(false);
445            if let Some(curr) = current {
446                curr.set_hovered(true);
447            }
448            true
449        }
450        (None, Some(curr)) => {
451            curr.set_hovered(true);
452            true
453        }
454        (None, None) => false,
455    }
456}
457
458pub fn hit_test<'a>(layout: &'a LayoutNode, info: &'a InfoNode, x: f32, y: f32) -> HitPath<'a> {
459    // Open popups are top-layer overlays: they render above every box and
460    // escape all ancestor clips, so they are tested first and shadow the box
461    // tree at their position.
462    if let Some(path) = hit_test_popup(layout, info, x, y) {
463        return path;
464    }
465    let size = layout
466        .layout_box
467        .iter()
468        .next()
469        .map_or((f32::INFINITY, f32::INFINITY), |b| {
470            (b.content_box.width, b.content_box.height)
471        });
472    let (sx, sy) = info.kind.scroll_offsets();
473    let root_viewport = StickyViewport {
474        top_left: (-sx, sy),
475        size,
476    };
477    hit_test_inner(layout, info, (x, y), (0.0, 0.0), root_viewport, size, true)
478}
479
480#[allow(clippy::too_many_arguments)]
481fn hit_test_inner<'a>(
482    layout: &'a LayoutNode,
483    info: &'a InfoNode,
484    point: (f32, f32),
485    accumulated_scroll: (f32, f32),
486    viewport: StickyViewport,
487    containing: (f32, f32),
488    is_root: bool,
489) -> HitPath<'a> {
490    if layout.layout_box.is_empty() {
491        return Vec::new();
492    }
493
494    let inline = is_inline(layout);
495    let inline_container = is_inline_container(layout, info);
496
497    let is_fixed = layout.style.position.kind == Position::Fixed;
498    let mut p = point;
499    if is_fixed {
500        p.0 -= accumulated_scroll.0;
501        p.1 -= accumulated_scroll.1;
502    }
503    let is_sticky = layout.style.position.kind == Position::Sticky;
504    let mut sticky_shift = (0.0, 0.0);
505    if is_sticky
506        && !is_fixed
507        && let Some(bm) = layout.layout_box.iter().next()
508        && let Some(edges) = bm.sticky_edges
509    {
510        sticky_shift = sticky_offset(&edges, &bm.border_box, viewport, containing);
511        p.0 -= sticky_shift.0;
512        p.1 -= sticky_shift.1;
513    }
514
515    let own_scroll = info.kind.scroll_offsets();
516    let child_scroll = if is_fixed {
517        own_scroll
518    } else {
519        (
520            accumulated_scroll.0 + own_scroll.0,
521            accumulated_scroll.1 + own_scroll.1,
522        )
523    };
524    let child_vp = child_viewport(layout, info, viewport, is_root);
525    let child_cont = child_containing(layout, containing);
526    let style = node_style(info);
527
528    // Walk boxes in reverse (front to back).
529    let boxes: Vec<_> = layout.layout_box.iter().collect();
530    for box_model in boxes.into_iter().rev() {
531        let Some((local_x, local_y)) = map_to_box_local(p, &box_model, &style, own_scroll, inline)
532        else {
533            continue;
534        };
535
536        // Inline containers push no transform and live in parent's coordinates.
537        let mut child_x = if inline_container { p.0 } else { local_x };
538        let child_y = if inline_container { p.1 } else { local_y };
539
540        if establishes_inline_box_space(layout) && !inline_container {
541            child_x -= box_model.content_box.x;
542        }
543
544        // 3. Children in front-to-back order.
545        for (child_layout, child_info) in layout.children.iter().zip(&info.children).rev() {
546            if let Some(child_node) = child_layout.node() {
547                let mut path = hit_test_inner(
548                    child_node,
549                    child_info,
550                    (child_x, child_y),
551                    child_scroll,
552                    child_vp,
553                    child_cont,
554                    false,
555                );
556                if !path.is_empty() {
557                    path.push(HitItem { layout, info });
558                    return path;
559                }
560            } else if let Some(result) = child_layout.custom_result()
561                && result.spans.iter().any(|span| {
562                    child_x >= span.line_pos.0
563                        && child_x <= span.line_pos.0 + span.width()
564                        && child_y >= span.line_pos.1
565                        && child_y <= span.line_pos.1 + result.box_model.content_box.height
566                })
567            {
568                return vec![
569                    HitItem {
570                        layout,
571                        info: child_info,
572                    },
573                    HitItem { layout, info },
574                ];
575            }
576        }
577
578        // 4. No child hit -> this box.
579        let _ = sticky_shift;
580        return vec![HitItem { layout, info }];
581    }
582
583    Vec::new()
584}
585
586/// Returns the hit path (child→parent) of the topmost open popup containing
587/// `(x, y)`, or `None`.
588fn hit_test_popup<'a>(
589    layout: &'a LayoutNode,
590    info: &'a InfoNode,
591    x: f32,
592    y: f32,
593) -> Option<HitPath<'a>> {
594    let mut best: Option<HitPath<'a>> = None;
595    let mut prefix: HitPath<'a> = Vec::new();
596    let size = layout
597        .layout_box
598        .iter()
599        .next()
600        .map_or((f32::INFINITY, f32::INFINITY), |b| {
601            (b.content_box.width, b.content_box.height)
602        });
603    let (sx, sy) = info.kind.scroll_offsets();
604    let root_viewport = StickyViewport {
605        top_left: (-sx, sy),
606        size,
607    };
608    hit_test_popup_inner(
609        layout,
610        info,
611        (x, y),
612        (0.0, 0.0),
613        root_viewport,
614        size,
615        true,
616        &mut prefix,
617        &mut best,
618    );
619    best
620}
621
622/// Recursive top-layer popup scan. `prefix` holds the root→current hit path;
623/// matches recorded later (tree order) overwrite earlier ones.
624#[allow(clippy::too_many_arguments)]
625fn hit_test_popup_inner<'a>(
626    layout: &'a LayoutNode,
627    info: &'a InfoNode,
628    point: (f32, f32),
629    accumulated_scroll: (f32, f32),
630    viewport: StickyViewport,
631    containing: (f32, f32),
632    is_root: bool,
633    prefix: &mut HitPath<'a>,
634    best: &mut Option<HitPath<'a>>,
635) {
636    if layout.layout_box.is_empty() {
637        return;
638    }
639
640    let inline_container = is_inline_container(layout, info);
641
642    let is_fixed = layout.style.position.kind == Position::Fixed;
643    let mut p = point;
644    if is_fixed {
645        p.0 -= accumulated_scroll.0;
646        p.1 -= accumulated_scroll.1;
647    }
648    let is_sticky = layout.style.position.kind == Position::Sticky;
649    if is_sticky
650        && !is_fixed
651        && let Some(bm) = layout.layout_box.iter().next()
652        && let Some(edges) = bm.sticky_edges
653    {
654        let (dx, dy) = sticky_offset(&edges, &bm.border_box, viewport, containing);
655        p.0 -= dx;
656        p.1 -= dy;
657    }
658
659    let own_scroll = info.kind.scroll_offsets();
660    let child_scroll = if is_fixed {
661        own_scroll
662    } else {
663        (
664            accumulated_scroll.0 + own_scroll.0,
665            accumulated_scroll.1 + own_scroll.1,
666        )
667    };
668    let child_vp = child_viewport(layout, info, viewport, is_root);
669    let child_cont = child_containing(layout, containing);
670    let style = node_style(info);
671
672    let (local_x, local_y) = if inline_container {
673        p
674    } else if let Some(bm) = layout.layout_box.iter().next() {
675        map_to_content_local(p, &bm, &style, own_scroll).unwrap_or(p)
676    } else {
677        p
678    };
679
680    if let NodeKind::Custom {
681        node,
682        text_style,
683        text_flow_style,
684        ..
685    } = &info.kind
686        && let Some(popup) = node.popup(text_style, text_flow_style)
687        && local_x >= popup.rect.x
688        && local_y >= popup.rect.y
689        && local_x <= popup.rect.x + popup.rect.width
690        && local_y <= popup.rect.y + popup.rect.height
691    {
692        let mut path = prefix.clone();
693        path.push(HitItem { layout, info });
694        path.reverse();
695        *best = Some(path);
696    }
697
698    // child space for descendants
699    let mut child_x = if inline_container { p.0 } else { local_x };
700    let child_y = if inline_container { p.1 } else { local_y };
701    if establishes_inline_box_space(layout)
702        && inline_container
703        && let Some(bm) = layout.layout_box.iter().next()
704    {
705        child_x -= bm.content_box.x;
706    }
707
708    prefix.push(HitItem { layout, info });
709    for (child_layout, child_info) in layout.children.iter().zip(&info.children) {
710        if let Some(child_node) = child_layout.node() {
711            hit_test_popup_inner(
712                child_node,
713                child_info,
714                (child_x, child_y),
715                child_scroll,
716                child_vp,
717                child_cont,
718                false,
719                prefix,
720                best,
721            );
722        }
723    }
724    prefix.pop();
725}
726
727/// Marker returned from [`scroll_at`] when a container scrolled but carries no
728/// snapshot dom id (so no `scroll` event can be dispatched to it). Callers can
729/// treat any `Some(..)` as "scrolled" and use this value to skip dispatch.
730pub const NO_SCROLL_DOM_ID: u32 = u32::MAX;
731
732/// Scrolls the innermost scrollable container under `(x, y)` by `(dx, dy)`.
733pub fn scroll_at(
734    layout: &LayoutNode,
735    info: &mut InfoNode,
736    viewport: (f32, f32),
737    x: f32,
738    y: f32,
739    dx: f32,
740    dy: f32,
741) -> Option<u32> {
742    let size = layout
743        .layout_box
744        .iter()
745        .next()
746        .map_or((f32::INFINITY, f32::INFINITY), |b| {
747            (b.content_box.width, b.content_box.height)
748        });
749    let (sx, sy) = info.kind.scroll_offsets();
750    let root_viewport = StickyViewport {
751        top_left: (-sx, sy),
752        size,
753    };
754    scroll_at_inner(
755        layout,
756        info,
757        viewport,
758        (x, y),
759        dx,
760        dy,
761        (0.0, 0.0),
762        root_viewport,
763        size,
764        true,
765    )
766}
767
768#[allow(clippy::too_many_arguments)]
769fn scroll_at_inner(
770    layout: &LayoutNode,
771    info: &mut InfoNode,
772    window_viewport: (f32, f32),
773    point: (f32, f32),
774    dx: f32,
775    dy: f32,
776    accumulated_scroll: (f32, f32),
777    viewport: StickyViewport,
778    containing: (f32, f32),
779    is_root: bool,
780) -> Option<u32> {
781    if layout.layout_box.is_empty() {
782        return None;
783    }
784
785    let inline = is_inline(layout);
786    let inline_container = is_inline_container(layout, info);
787
788    let is_fixed = layout.style.position.kind == Position::Fixed;
789    let mut p = point;
790    if is_fixed {
791        p.0 -= accumulated_scroll.0;
792        p.1 -= accumulated_scroll.1;
793    }
794    let is_sticky = layout.style.position.kind == Position::Sticky;
795    if is_sticky
796        && !is_fixed
797        && let Some(bm) = layout.layout_box.iter().next()
798        && let Some(edges) = bm.sticky_edges
799    {
800        let (sdx, sdy) = sticky_offset(&edges, &bm.border_box, viewport, containing);
801        p.0 -= sdx;
802        p.1 -= sdy;
803    }
804
805    let own_scroll = info.kind.scroll_offsets();
806    let child_scroll = if is_fixed {
807        own_scroll
808    } else {
809        (
810            accumulated_scroll.0 + own_scroll.0,
811            accumulated_scroll.1 + own_scroll.1,
812        )
813    };
814    let child_vp = child_viewport(layout, info, viewport, is_root);
815    let child_cont = child_containing(layout, containing);
816    let style = node_style(info);
817
818    let boxes: Vec<_> = layout.layout_box.iter().collect();
819    for box_model in boxes.into_iter().rev() {
820        let Some((local_x, local_y)) = map_to_box_local(p, &box_model, &style, own_scroll, inline)
821        else {
822            continue;
823        };
824        let mut child_x = if inline_container { p.0 } else { local_x };
825        let child_y = if inline_container { p.1 } else { local_y };
826
827        if establishes_inline_box_space(layout) && !inline_container {
828            child_x -= box_model.content_box.x;
829        }
830
831        // children front-to-back
832        let mut i = info.children.len();
833        while i > 0 {
834            i -= 1;
835            let Some(child_node) = layout.children.get(i).and_then(|c| c.node()) else {
836                continue;
837            };
838            if let Some(scrolled_id) = scroll_at_inner(
839                child_node,
840                &mut info.children[i],
841                window_viewport,
842                (child_x, child_y),
843                dx,
844                dy,
845                child_scroll,
846                child_vp,
847                child_cont,
848                false,
849            ) {
850                return Some(scrolled_id);
851            }
852        }
853
854        let scrolled = match &mut info.kind {
855            NodeKind::Container {
856                scroll_x,
857                scroll_y,
858                scroll_offset_x,
859                scroll_offset_y,
860                ..
861            }
862            | NodeKind::Custom {
863                scroll_x,
864                scroll_y,
865                scroll_offset_x,
866                scroll_offset_y,
867                ..
868            } => {
869                let mut changed = false;
870                let (vw, vh) = window_viewport;
871
872                if *scroll_y {
873                    let max_scroll = (box_model.children_box.height
874                        - box_model.content_box.height.min(vh))
875                    .max(0.0);
876                    let next = (*scroll_offset_y + dy).clamp(0.0, max_scroll);
877                    if (next - *scroll_offset_y).abs() > f32::EPSILON {
878                        changed = true;
879                    }
880                    *scroll_offset_y = next;
881                }
882                if *scroll_x {
883                    let max_scroll = (box_model.children_box.width
884                        - box_model.content_box.width.min(vw))
885                    .max(0.0);
886                    let next = (*scroll_offset_x + dx).clamp(0.0, max_scroll);
887                    if (next - *scroll_offset_x).abs() > f32::EPSILON {
888                        changed = true;
889                    }
890                    *scroll_offset_x = next;
891                }
892                changed
893            }
894            _ => false,
895        };
896        if scrolled {
897            return Some(info.dom_id.unwrap_or(NO_SCROLL_DOM_ID));
898        }
899    }
900
901    None
902}
903
904/// Focuses `target` and clears focus from every other text input.
905///
906/// Returns whether a text input received focus.
907pub fn focus_text_input(info: &InfoNode, target: Option<&Arc<dyn CustomNode>>) -> bool {
908    let mut focused = false;
909    if let NodeKind::Custom { node, .. } = &info.kind
910        && node.accepts_text_input()
911    {
912        let is_target = target.is_some_and(|target| Arc::ptr_eq(node, target));
913        node.set_focused(is_target);
914        focused |= is_target;
915    }
916    for child in &info.children {
917        focused |= focus_text_input(child, target);
918    }
919    focused
920}
921
922/// Sends an editing event to the focused text input, if one exists.
923pub fn dispatch_text_input(info: &InfoNode, event: InputTextEvent) -> bool {
924    if let NodeKind::Custom { node, .. } = &info.kind
925        && node.accepts_text_input()
926        && node.is_focused()
927    {
928        return node.handle_text_input(event);
929    }
930    for child in &info.children {
931        if dispatch_text_input(child, event.clone()) {
932            return true;
933        }
934    }
935    false
936}
937
938/// Returns whether the focused text input has an active IME composition.
939pub fn focused_text_input_is_composing(info: &InfoNode) -> bool {
940    if let NodeKind::Custom { node, .. } = &info.kind
941        && node.accepts_text_input()
942        && node.is_focused()
943    {
944        return node.is_composing();
945    }
946    info.children.iter().any(focused_text_input_is_composing)
947}
948
949/// Returns whether any custom node in the tree reports a pending repaint.
950///
951/// Consumes the repaint flags of the nodes it visits, so callers should only
952/// invoke this once per frame, right before deciding whether to redraw.
953pub fn any_custom_node_needs_repaint(info: &InfoNode) -> bool {
954    if let NodeKind::Custom { node, .. } = &info.kind
955        && node.needs_repaint()
956    {
957        return true;
958    }
959    info.children.iter().any(any_custom_node_needs_repaint)
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965    use crate::engine::bridge::text::{FallbackTextMeasurer, TextMeasurer};
966    use crate::engine::layouter::types::{ContainerRole, ContainerStyle, TextStyle};
967    use crate::engine::renderer_model::{DrawCommand, Rect};
968    use crate::engine::ui::button::ButtonComponent;
969    use crate::engine::ui::input_text::InputTextComponent;
970    use crate::engine::ui::input_text_types::InputTextEvent;
971    use crate::engine::ui::{ContentSize, Popup};
972    use std::sync::Arc;
973    use std::sync::Mutex;
974    use std::sync::atomic::{AtomicBool, Ordering};
975    use ui_layout::{LayoutChild, Style};
976
977    const VIEWPORT_WIDTH: f32 = 800.0;
978    const VIEWPORT_HEIGHT: f32 = 600.0;
979
980    fn input_info(node: Arc<dyn CustomNode>) -> InfoNode {
981        InfoNode {
982            kind: NodeKind::Custom {
983                node,
984                scroll_x: false,
985                scroll_y: false,
986                scroll_offset_x: 0.0,
987                scroll_offset_y: 0.0,
988                style: ContainerStyle::default(),
989                layout_style: ui_layout::Style::default(),
990                text_style: TextStyle::default(),
991                text_flow_style: TextFlowStyle::default(),
992            },
993            children: Vec::new(),
994            dom_id: None,
995        }
996    }
997
998    #[test]
999    fn focus_and_dispatch_target_one_input() {
1000        let measurer: Arc<dyn TextMeasurer> = Arc::new(FallbackTextMeasurer);
1001        let first: Arc<dyn CustomNode> =
1002            Arc::new(InputTextComponent::new("", "", Arc::clone(&measurer)));
1003        let second: Arc<dyn CustomNode> = Arc::new(InputTextComponent::new("", "", measurer));
1004        let root = InfoNode {
1005            kind: NodeKind::LineBreak,
1006            children: vec![
1007                input_info(Arc::clone(&first)),
1008                input_info(Arc::clone(&second)),
1009            ],
1010            dom_id: None,
1011        };
1012
1013        assert!(focus_text_input(&root, Some(&second)));
1014        assert!(!first.is_focused());
1015        assert!(second.is_focused());
1016        assert!(dispatch_text_input(
1017            &root,
1018            InputTextEvent::Commit("日本".into())
1019        ));
1020    }
1021
1022    #[test]
1023    fn hit_custom_node_finds_innermost_custom() {
1024        let node: Arc<dyn CustomNode> = Arc::new(InputTextComponent::new(
1025            "",
1026            "",
1027            Arc::new(FallbackTextMeasurer),
1028        ));
1029        let info = input_info(Arc::clone(&node));
1030        let layout = LayoutNode::new(ui_layout::Style::default());
1031        let path = vec![
1032            HitItem {
1033                layout: &layout,
1034                info: &info,
1035            },
1036            HitItem {
1037                layout: &layout,
1038                info: &info,
1039            },
1040        ];
1041        assert!(Arc::ptr_eq(hit_custom_node(&path).unwrap(), &node));
1042    }
1043
1044    #[test]
1045    fn update_hover_switches_target() {
1046        let measurer: Arc<dyn TextMeasurer> = Arc::new(FallbackTextMeasurer);
1047        let a: Arc<dyn CustomNode> = Arc::new(ButtonComponent::new("A", Arc::clone(&measurer)));
1048        let b: Arc<dyn CustomNode> = Arc::new(ButtonComponent::new("B", measurer));
1049        let info_a = input_info(Arc::clone(&a));
1050        let info_b = input_info(Arc::clone(&b));
1051        let layout = LayoutNode::new(ui_layout::Style::default());
1052        let path_a = vec![HitItem {
1053            layout: &layout,
1054            info: &info_a,
1055        }];
1056        let path_b = vec![HitItem {
1057            layout: &layout,
1058            info: &info_b,
1059        }];
1060
1061        assert!(update_hover(&path_a, None));
1062        assert!(a.is_hovered());
1063        assert!(!b.is_hovered());
1064
1065        assert!(update_hover(&path_b, Some(&a)));
1066        assert!(!a.is_hovered());
1067        assert!(b.is_hovered());
1068
1069        assert!(!update_hover(&path_b, Some(&b)));
1070    }
1071
1072    #[test]
1073    fn any_custom_node_needs_repaint_tracks_dirty_nodes() {
1074        let measurer: Arc<dyn TextMeasurer> = Arc::new(FallbackTextMeasurer);
1075        let a: Arc<dyn CustomNode> = Arc::new(ButtonComponent::new("A", Arc::clone(&measurer)));
1076        let info_a = input_info(Arc::clone(&a));
1077
1078        // Fresh nodes are dirty (initial paint).
1079        assert!(any_custom_node_needs_repaint(&info_a));
1080        assert!(!any_custom_node_needs_repaint(&info_a));
1081
1082        // A pointer event that changes visual state marks it dirty again.
1083        a.on_pointer_event(PointerEvent::Down { x: 0.0, y: 0.0 });
1084        assert!(any_custom_node_needs_repaint(&info_a));
1085        assert!(!any_custom_node_needs_repaint(&info_a));
1086    }
1087
1088    fn box_model(
1089        x: f32,
1090        y: f32,
1091        width: f32,
1092        height: f32,
1093        children_width: f32,
1094        children_height: f32,
1095    ) -> ui_layout::BoxModel {
1096        let rect = ui_layout::Rect {
1097            x,
1098            y,
1099            width,
1100            height,
1101        };
1102        ui_layout::BoxModel {
1103            sticky_edges: None,
1104            border_box: rect,
1105            padding_box: rect,
1106            content_box: rect,
1107            children_box: ui_layout::Rect {
1108                x,
1109                y,
1110                width: children_width,
1111                height: children_height,
1112            },
1113        }
1114    }
1115
1116    fn scrollable_info() -> InfoNode {
1117        InfoNode {
1118            kind: NodeKind::Container {
1119                scroll_x: false,
1120                scroll_y: true,
1121                scroll_offset_x: 0.0,
1122                scroll_offset_y: 0.0,
1123                style: ContainerStyle::default(),
1124                role: crate::engine::layouter::types::ContainerRole::Normal,
1125            },
1126            children: Vec::new(),
1127            dom_id: None,
1128        }
1129    }
1130
1131    fn container_info(scroll_y: bool, dom_id: Option<u32>) -> InfoNode {
1132        InfoNode {
1133            kind: NodeKind::Container {
1134                scroll_x: false,
1135                scroll_y,
1136                scroll_offset_x: 0.0,
1137                scroll_offset_y: 0.0,
1138                style: ContainerStyle::default(),
1139                role: crate::engine::layouter::types::ContainerRole::Normal,
1140            },
1141            children: Vec::new(),
1142            dom_id,
1143        }
1144    }
1145
1146    /// An inline container laid out in its parent's content space: its box
1147    /// model and line spans use absolute coordinates (mirroring how the flow
1148    /// engine positions inline content).
1149    fn inline_node(children: Vec<LayoutChild>) -> LayoutNode {
1150        let mut node = LayoutNode::with_children(ui_layout::Style::default(), children);
1151        node.layout_box = ui_layout::LayoutBox::InlineBox(ui_layout::InlineBox {
1152            box_model: box_model(10.0, 10.0, 200.0, 20.0, 200.0, 20.0),
1153            line_spans: vec![ui_layout::LineSpan {
1154                x_range: 0.0..200.0,
1155                line_pos: (10.0, 10.0),
1156                line_index: 0,
1157            }],
1158        });
1159        node
1160    }
1161
1162    fn scroll_offset_y_of(info: &InfoNode) -> f32 {
1163        let NodeKind::Container {
1164            scroll_offset_y, ..
1165        } = &info.kind
1166        else {
1167            panic!("expected container");
1168        };
1169        *scroll_offset_y
1170    }
1171
1172    fn set_vertical_scroll(info: &mut InfoNode, offset: f32) {
1173        let NodeKind::Container {
1174            scroll_offset_y, ..
1175        } = &mut info.kind
1176        else {
1177            panic!("expected container");
1178        };
1179        *scroll_offset_y = offset;
1180    }
1181
1182    fn fixed_child_layout(children_height: f32) -> LayoutNode {
1183        let mut style = ui_layout::Style::default();
1184        style.position.kind = Position::Fixed;
1185        let mut child = LayoutNode::new(style);
1186        child.layout_box = ui_layout::LayoutBox::BlockBox(box_model(
1187            10.0,
1188            10.0,
1189            30.0,
1190            20.0,
1191            30.0,
1192            children_height,
1193        ));
1194        child
1195    }
1196
1197    #[test]
1198    fn hit_test_fixed_child_ignores_ancestor_scroll() {
1199        let mut layout =
1200            LayoutNode::with_children(ui_layout::Style::default(), [fixed_child_layout(20.0)]);
1201        layout.layout_box =
1202            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
1203
1204        let mut info = scrollable_info();
1205        set_vertical_scroll(&mut info, 50.0);
1206        let mut fixed_info = scrollable_info();
1207        fixed_info.dom_id = Some(42);
1208        info.children.push(fixed_info);
1209
1210        let path = hit_test(&layout, &info, 15.0, 15.0);
1211        assert_eq!(hit_dom_id(&path), Some(42));
1212    }
1213
1214    #[test]
1215    fn scroll_at_fixed_child_ignores_ancestor_scroll() {
1216        let mut layout =
1217            LayoutNode::with_children(ui_layout::Style::default(), [fixed_child_layout(80.0)]);
1218        layout.layout_box =
1219            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
1220
1221        let mut info = scrollable_info();
1222        set_vertical_scroll(&mut info, 50.0);
1223        info.children.push(scrollable_info());
1224
1225        assert!(
1226            scroll_at(
1227                &layout,
1228                &mut info,
1229                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1230                15.0,
1231                15.0,
1232                0.0,
1233                10.0
1234            )
1235            .is_some()
1236        );
1237        let NodeKind::Container {
1238            scroll_offset_y: child_scroll,
1239            ..
1240        } = &info.children[0].kind
1241        else {
1242            panic!("expected fixed child container");
1243        };
1244        assert_eq!(*child_scroll, 10.0);
1245        let NodeKind::Container {
1246            scroll_offset_y: parent_scroll,
1247            ..
1248        } = &info.kind
1249        else {
1250            panic!("expected parent container");
1251        };
1252        assert_eq!(*parent_scroll, 50.0);
1253    }
1254
1255    #[test]
1256    fn scroll_at_scrolls_under_point_clamped_to_range() {
1257        let mut layout = LayoutNode::new(ui_layout::Style::default());
1258        layout.layout_box =
1259            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
1260        let mut info = scrollable_info();
1261
1262        // Cursor inside the box. Positive dy scrolls down.
1263        assert!(
1264            scroll_at(
1265                &layout,
1266                &mut info,
1267                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1268                50.0,
1269                50.0,
1270                0.0,
1271                100.0
1272            )
1273            .is_some()
1274        );
1275        let NodeKind::Container {
1276            scroll_offset_y, ..
1277        } = &info.kind
1278        else {
1279            panic!("expected container");
1280        };
1281        assert_eq!(*scroll_offset_y, 100.0);
1282
1283        // Clamp to children_box.height - content_box.height = 200.
1284        assert!(
1285            scroll_at(
1286                &layout,
1287                &mut info,
1288                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1289                50.0,
1290                50.0,
1291                0.0,
1292                300.0
1293            )
1294            .is_some()
1295        );
1296        let NodeKind::Container {
1297            scroll_offset_y, ..
1298        } = &info.kind
1299        else {
1300            panic!("expected container");
1301        };
1302        assert_eq!(*scroll_offset_y, 200.0);
1303
1304        // Cannot scroll past 0 (negative dy scrolls up).
1305        assert!(
1306            scroll_at(
1307                &layout,
1308                &mut info,
1309                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1310                50.0,
1311                50.0,
1312                0.0,
1313                -500.0
1314            )
1315            .is_some()
1316        );
1317        let NodeKind::Container {
1318            scroll_offset_y, ..
1319        } = &info.kind
1320        else {
1321            panic!("expected container");
1322        };
1323        assert_eq!(*scroll_offset_y, 0.0);
1324    }
1325
1326    #[test]
1327    fn scroll_at_ignores_cursor_outside_box() {
1328        let mut layout = LayoutNode::new(ui_layout::Style::default());
1329        layout.layout_box =
1330            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
1331        let mut info = scrollable_info();
1332
1333        assert!(
1334            scroll_at(
1335                &layout,
1336                &mut info,
1337                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1338                250.0,
1339                50.0,
1340                0.0,
1341                -100.0
1342            )
1343            .is_none()
1344        );
1345    }
1346
1347    #[test]
1348    fn scroll_at_ignores_non_scrollable_containers() {
1349        let mut layout = LayoutNode::new(ui_layout::Style::default());
1350        layout.layout_box =
1351            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
1352        let mut info = InfoNode {
1353            kind: NodeKind::Container {
1354                scroll_x: false,
1355                scroll_y: false,
1356                scroll_offset_x: 0.0,
1357                scroll_offset_y: 0.0,
1358                style: ContainerStyle::default(),
1359                role: crate::engine::layouter::types::ContainerRole::Normal,
1360            },
1361            children: Vec::new(),
1362            dom_id: None,
1363        };
1364
1365        assert!(
1366            scroll_at(
1367                &layout,
1368                &mut info,
1369                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1370                50.0,
1371                50.0,
1372                0.0,
1373                -100.0
1374            )
1375            .is_none()
1376        );
1377        let NodeKind::Container {
1378            scroll_offset_y, ..
1379        } = &info.kind
1380        else {
1381            panic!("expected container");
1382        };
1383        assert_eq!(*scroll_offset_y, 0.0);
1384    }
1385
1386    #[test]
1387    fn scroll_at_prefers_innermost_scrollable_container() {
1388        // Outer container with scrollable content; inner container is
1389        // scrollable and sits inside it. A scroll over the inner container
1390        // should move the inner one, not the outer.
1391        let outer_children_box = box_model(0.0, 0.0, 400.0, 300.0, 400.0, 900.0);
1392        let inner_children_box = box_model(10.0, 10.0, 100.0, 80.0, 100.0, 240.0);
1393
1394        let mut outer_layout = LayoutNode::with_children(
1395            ui_layout::Style::default(),
1396            [LayoutNode::new(ui_layout::Style::default())],
1397        );
1398        outer_layout.layout_box = ui_layout::LayoutBox::BlockBox(outer_children_box);
1399
1400        let mut inner_layout = LayoutNode::new(ui_layout::Style::default());
1401        inner_layout.layout_box = ui_layout::LayoutBox::BlockBox(inner_children_box);
1402
1403        outer_layout.children[0] = ui_layout::LayoutChild::Node(Box::new(inner_layout));
1404
1405        let mut outer_info = scrollable_info();
1406        let inner_info = scrollable_info();
1407        outer_info.children.push(inner_info);
1408
1409        // Cursor over the inner container. Positive dy scrolls down.
1410        assert!(
1411            scroll_at(
1412                &outer_layout,
1413                &mut outer_info,
1414                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1415                50.0,
1416                50.0,
1417                0.0,
1418                30.0
1419            )
1420            .is_some()
1421        );
1422        let NodeKind::Container {
1423            scroll_offset_y, ..
1424        } = &outer_info.children[0].kind
1425        else {
1426            panic!("expected inner container");
1427        };
1428        assert_eq!(*scroll_offset_y, 30.0);
1429        let NodeKind::Container {
1430            scroll_offset_y: outer_off,
1431            ..
1432        } = &outer_info.kind
1433        else {
1434            panic!("expected outer container");
1435        };
1436        assert_eq!(*outer_off, 0.0);
1437    }
1438
1439    #[test]
1440    fn scroll_at_reports_the_scrolled_containers_dom_id() {
1441        // Outer (dom 7) with scrollable inner (dom 9): scrolling over the inner
1442        // reports the inner's dom id.
1443        let outer_children_box = box_model(0.0, 0.0, 400.0, 300.0, 400.0, 900.0);
1444        let inner_children_box = box_model(10.0, 10.0, 100.0, 80.0, 100.0, 240.0);
1445
1446        let mut outer_layout = LayoutNode::with_children(
1447            ui_layout::Style::default(),
1448            [LayoutNode::new(ui_layout::Style::default())],
1449        );
1450        outer_layout.layout_box = ui_layout::LayoutBox::BlockBox(outer_children_box);
1451        let mut inner_layout = LayoutNode::new(ui_layout::Style::default());
1452        inner_layout.layout_box = ui_layout::LayoutBox::BlockBox(inner_children_box);
1453        outer_layout.children[0] = ui_layout::LayoutChild::Node(Box::new(inner_layout));
1454
1455        let mut outer_info = container_info(true, Some(7));
1456        let inner_info = container_info(true, Some(9));
1457        outer_info.children.push(inner_info);
1458
1459        let scrolled = scroll_at(
1460            &outer_layout,
1461            &mut outer_info,
1462            (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1463            50.0,
1464            50.0,
1465            0.0,
1466            30.0,
1467        );
1468        assert_eq!(scrolled, Some(9));
1469    }
1470
1471    #[test]
1472    fn scroll_at_reports_marker_when_scrolled_container_has_no_dom_id() {
1473        let mut layout = LayoutNode::new(ui_layout::Style::default());
1474        layout.layout_box =
1475            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
1476        let mut info = container_info(true, None);
1477        let scrolled = scroll_at(
1478            &layout,
1479            &mut info,
1480            (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1481            50.0,
1482            50.0,
1483            0.0,
1484            10.0,
1485        );
1486        assert_eq!(scrolled, Some(NO_SCROLL_DOM_ID));
1487    }
1488
1489    /// Records pointer events for asserting `dispatch_pointer` coordinates.
1490    #[derive(Debug)]
1491    struct RecordingNode {
1492        pointer_events: Mutex<Vec<PointerEvent>>,
1493        popup_events: Mutex<Vec<PointerEvent>>,
1494        popup_rect: Rect,
1495        popup_open: AtomicBool,
1496        dismissed: AtomicBool,
1497    }
1498
1499    impl RecordingNode {
1500        fn new(popup_rect: Rect) -> Self {
1501            RecordingNode {
1502                pointer_events: Mutex::new(Vec::new()),
1503                popup_events: Mutex::new(Vec::new()),
1504                popup_rect,
1505                popup_open: AtomicBool::new(true),
1506                dismissed: AtomicBool::new(false),
1507            }
1508        }
1509
1510        fn events(&self) -> Vec<PointerEvent> {
1511            self.pointer_events.lock().unwrap().clone()
1512        }
1513
1514        fn popup_events(&self) -> Vec<PointerEvent> {
1515            self.popup_events.lock().unwrap().clone()
1516        }
1517    }
1518
1519    impl CustomNode for RecordingNode {
1520        fn draw_sized(
1521            &self,
1522            _cmd_buf: &mut Vec<DrawCommand>,
1523            _text_style: &TextStyle,
1524            _text_flow_style: &TextFlowStyle,
1525            _style: &Style,
1526            _size: ContentSize,
1527        ) {
1528        }
1529
1530        fn intrinsic_size(&self) -> ContentSize {
1531            ContentSize {
1532                width: 120.0,
1533                height: 28.0,
1534            }
1535        }
1536
1537        fn on_pointer_event(&self, event: PointerEvent) -> bool {
1538            self.pointer_events.lock().unwrap().push(event);
1539            true
1540        }
1541
1542        fn popup(
1543            &self,
1544            _text_style: &TextStyle,
1545            _text_flow_style: &TextFlowStyle,
1546        ) -> Option<Popup> {
1547            self.popup_open.load(Ordering::Relaxed).then(|| Popup {
1548                rect: self.popup_rect,
1549                commands: Vec::new(),
1550            })
1551        }
1552
1553        fn on_popup_pointer_event(&self, event: PointerEvent) -> bool {
1554            self.popup_events.lock().unwrap().push(event);
1555            true
1556        }
1557
1558        fn dismiss_popup(&self) {
1559            self.dismissed.store(true, Ordering::Relaxed);
1560        }
1561    }
1562
1563    /// Tree: root (0,0,200,200) → container (10,20,160,100, scroll_y) →
1564    /// custom node (5,0,120,28) with an open popup at (0,28,120,84).
1565    fn make_tree(a_scroll_y: f32) -> (LayoutNode, InfoNode, Arc<RecordingNode>) {
1566        let node: Arc<RecordingNode> = Arc::new(RecordingNode::new(Rect {
1567            x: 0.0,
1568            y: 28.0,
1569            width: 120.0,
1570            height: 84.0,
1571        }));
1572        let custom_info = input_info(Arc::clone(&node) as Arc<dyn CustomNode>);
1573
1574        let mut a_layout = LayoutNode::new(ui_layout::Style::default());
1575        a_layout.layout_box =
1576            ui_layout::LayoutBox::BlockBox(box_model(10.0, 20.0, 160.0, 100.0, 160.0, 300.0));
1577        let mut custom_layout = LayoutNode::new(ui_layout::Style::default());
1578        custom_layout.layout_box =
1579            ui_layout::LayoutBox::BlockBox(box_model(5.0, 0.0, 120.0, 28.0, 120.0, 28.0));
1580        a_layout.children = vec![LayoutChild::Node(Box::new(custom_layout))];
1581
1582        let mut root_layout = LayoutNode::with_children(ui_layout::Style::default(), [a_layout]);
1583        root_layout.layout_box =
1584            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 200.0, 200.0, 200.0));
1585
1586        let a_info = InfoNode {
1587            kind: NodeKind::Container {
1588                scroll_x: false,
1589                scroll_y: true,
1590                scroll_offset_x: 0.0,
1591                scroll_offset_y: a_scroll_y,
1592                style: ContainerStyle::default(),
1593                role: ContainerRole::Normal,
1594            },
1595            children: vec![custom_info],
1596            dom_id: None,
1597        };
1598        let root_info = InfoNode {
1599            kind: NodeKind::Container {
1600                scroll_x: false,
1601                scroll_y: false,
1602                scroll_offset_x: 0.0,
1603                scroll_offset_y: 0.0,
1604                style: ContainerStyle::default(),
1605                role: ContainerRole::Normal,
1606            },
1607            children: vec![a_info],
1608            dom_id: None,
1609        };
1610
1611        (root_layout, root_info, node)
1612    }
1613
1614    /// Builds the child→parent hit path matching `make_tree`.
1615    fn build_path<'a>(root_layout: &'a LayoutNode, root_info: &'a InfoNode) -> HitPath<'a> {
1616        let a_layout = match &root_layout.children[0] {
1617            LayoutChild::Node(node) => node,
1618            _ => unreachable!("expected container child"),
1619        };
1620        let custom_layout = match &a_layout.children[0] {
1621            LayoutChild::Node(node) => node,
1622            _ => unreachable!("expected custom child"),
1623        };
1624        let a_info = &root_info.children[0];
1625        let custom_info = &a_info.children[0];
1626        vec![
1627            HitItem {
1628                layout: custom_layout,
1629                info: custom_info,
1630            },
1631            HitItem {
1632                layout: a_layout,
1633                info: a_info,
1634            },
1635            HitItem {
1636                layout: root_layout,
1637                info: root_info,
1638            },
1639        ]
1640    }
1641
1642    #[test]
1643    fn dispatch_pointer_uses_node_local_coords() {
1644        let (root_layout, root_info, node) = make_tree(0.0);
1645        node.popup_open.store(false, Ordering::Relaxed);
1646        let path = build_path(&root_layout, &root_info);
1647
1648        // Content origin (15,20); no scroll: (20,25) → local (5,5).
1649        dispatch_pointer(&path, PointerEvent::Down { x: 20.0, y: 25.0 });
1650        assert_eq!(node.events(), vec![PointerEvent::Down { x: 5.0, y: 5.0 }]);
1651    }
1652
1653    #[test]
1654    fn dispatch_pointer_folds_ancestor_scroll_into_local_coords() {
1655        let (root_layout, root_info, node) = make_tree(50.0);
1656        node.popup_open.store(false, Ordering::Relaxed);
1657        let path = build_path(&root_layout, &root_info);
1658
1659        // Same click with a 50px ancestor scroll: local y += 50.
1660        dispatch_pointer(&path, PointerEvent::Move { x: 20.0, y: 25.0 });
1661        assert_eq!(node.events(), vec![PointerEvent::Move { x: 5.0, y: 55.0 }]);
1662    }
1663
1664    #[test]
1665    fn popup_events_use_popup_local_coords() {
1666        let (root_layout, root_info, node) = make_tree(0.0);
1667        let path = build_path(&root_layout, &root_info);
1668
1669        // Global (20,60) → local (5,40) → inside popup (y in 28..112) →
1670        // popup-local (5, 40-28=12).
1671        dispatch_pointer(&path, PointerEvent::Down { x: 20.0, y: 60.0 });
1672        assert_eq!(
1673            node.popup_events(),
1674            vec![PointerEvent::Down { x: 5.0, y: 12.0 }]
1675        );
1676        assert!(node.events().is_empty());
1677        assert!(!node.dismissed.load(Ordering::Relaxed));
1678    }
1679
1680    #[test]
1681    fn down_outside_popup_routes_to_node() {
1682        let (root_layout, root_info, node) = make_tree(0.0);
1683        let path = build_path(&root_layout, &root_info);
1684
1685        // Global (20,30) → local (5,10), above the popup: the node receives
1686        // the press (dismissal is handled globally by `dismiss_open_popups`).
1687        dispatch_pointer(&path, PointerEvent::Down { x: 20.0, y: 30.0 });
1688        assert!(!node.dismissed.load(Ordering::Relaxed));
1689        assert_eq!(node.events(), vec![PointerEvent::Down { x: 5.0, y: 10.0 }]);
1690        assert!(node.popup_events().is_empty());
1691    }
1692
1693    #[test]
1694    fn move_outside_popup_routes_to_node() {
1695        let (root_layout, root_info, node) = make_tree(0.0);
1696        let path = build_path(&root_layout, &root_info);
1697
1698        // Global (20,30) → local (5,10), above the popup: the node receives
1699        // the move and the popup stays open.
1700        dispatch_pointer(&path, PointerEvent::Move { x: 20.0, y: 30.0 });
1701        assert!(!node.dismissed.load(Ordering::Relaxed));
1702        assert_eq!(node.events(), vec![PointerEvent::Move { x: 5.0, y: 10.0 }]);
1703        assert!(node.popup_events().is_empty());
1704    }
1705
1706    fn popup_hit_assert(
1707        root_layout: &LayoutNode,
1708        root_info: &InfoNode,
1709        node: &Arc<RecordingNode>,
1710        x: f32,
1711        y: f32,
1712    ) {
1713        let path = hit_test(root_layout, root_info, x, y);
1714        let hit = hit_custom_node(&path).unwrap();
1715        assert!(std::ptr::eq(hit.as_ref(), node.as_ref() as &dyn CustomNode));
1716    }
1717
1718    #[test]
1719    fn hit_test_finds_open_popup() {
1720        let (root_layout, root_info, node) = make_tree(0.0);
1721        // Custom page origin (15,20); popup page rect (15,48)-(135,132).
1722        // Global (20,60) → local (5,40), inside the popup (y in 28..112).
1723        popup_hit_assert(&root_layout, &root_info, &node, 20.0, 60.0);
1724    }
1725
1726    #[test]
1727    fn hit_test_ignores_closed_popup() {
1728        let (root_layout, root_info, node) = make_tree(0.0);
1729        node.popup_open.store(false, Ordering::Relaxed);
1730        // Local (5,40) is below the custom box (y in 0..28); with the popup
1731        // closed the click lands on the container instead.
1732        let path = hit_test(&root_layout, &root_info, 20.0, 60.0);
1733        assert_eq!(path.len(), 2);
1734        assert!(hit_custom_node(&path).is_none());
1735    }
1736
1737    #[test]
1738    fn hit_test_finds_popup_escaping_ancestor_box() {
1739        let (root_layout, root_info, node) = make_tree(0.0);
1740        // The container box ends at y=120 but the popup reaches y=132. A click
1741        // past the container is still a popup hit because popups render above
1742        // ancestor boxes and clips.
1743        popup_hit_assert(&root_layout, &root_info, &node, 20.0, 125.0);
1744    }
1745
1746    #[test]
1747    fn hit_test_folds_scroll_into_popup_hit() {
1748        let (root_layout, root_info, node) = make_tree(50.0);
1749        // With the 50px ancestor scroll, a click at page y=25 maps to local
1750        // (5,55), inside the popup (y in 28..112).
1751        popup_hit_assert(&root_layout, &root_info, &node, 20.0, 25.0);
1752    }
1753
1754    #[test]
1755    fn dismiss_open_popups_closes_popup_on_outside_press() {
1756        let (root_layout, root_info, node) = make_tree(0.0);
1757        // (10,25) lands on the container, outside the custom box and popup.
1758        let path = hit_test(&root_layout, &root_info, 10.0, 25.0);
1759        assert!(dismiss_open_popups(&root_info, &path, 50.0, 50.0));
1760        assert!(node.dismissed.load(Ordering::Relaxed));
1761    }
1762
1763    #[test]
1764    fn dismiss_open_popups_keeps_popup_under_press() {
1765        let (root_layout, root_info, node) = make_tree(0.0);
1766        // Press on the open popup itself.
1767        let path = hit_test(&root_layout, &root_info, 20.0, 60.0);
1768        assert!(!dismiss_open_popups(&root_info, &path, 50.0, 50.0));
1769        assert!(!node.dismissed.load(Ordering::Relaxed));
1770    }
1771
1772    #[test]
1773    fn dismiss_open_popups_keeps_popup_when_press_on_owner_box() {
1774        let (root_layout, root_info, node) = make_tree(0.0);
1775        // Press on the owning box (not the popup): the owner closes it itself,
1776        // so no separate dismissal happens.
1777        let path = hit_test(&root_layout, &root_info, 20.0, 30.0);
1778        assert!(!dismiss_open_popups(&root_info, &path, 50.0, 50.0));
1779        assert!(!node.dismissed.load(Ordering::Relaxed));
1780    }
1781
1782    #[test]
1783    fn dismiss_open_popups_ignores_closed_popup() {
1784        let (root_layout, root_info, node) = make_tree(0.0);
1785        node.popup_open.store(false, Ordering::Relaxed);
1786        let path = hit_test(&root_layout, &root_info, 10.0, 25.0);
1787        assert!(!dismiss_open_popups(&root_info, &path, 50.0, 50.0));
1788        assert!(!node.dismissed.load(Ordering::Relaxed));
1789    }
1790
1791    /// Tree: block `b` (0,0,300,100) → inline `i` (line box 10,10,200,20) →
1792    /// block child `c` (30,10,50,20). Inline content shares the block's
1793    /// content space, so `c` sits at absolute (30,10) inside `b`.
1794    fn make_inline_tree() -> (LayoutNode, InfoNode) {
1795        let mut c = LayoutNode::new(ui_layout::Style::default());
1796        c.layout_box =
1797            ui_layout::LayoutBox::BlockBox(box_model(30.0, 10.0, 50.0, 20.0, 50.0, 20.0));
1798        let mut b =
1799            LayoutNode::with_children(ui_layout::Style::default(), [inline_node(vec![c.into()])]);
1800        b.layout_box =
1801            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 300.0, 100.0, 300.0, 100.0));
1802
1803        let c_info = container_info(false, Some(7));
1804        let mut i_info = container_info(false, None);
1805        i_info.children.push(c_info);
1806        let mut b_info = container_info(false, None);
1807        b_info.children.push(i_info);
1808
1809        (b, b_info)
1810    }
1811
1812    #[test]
1813    fn hit_test_inside_inline_keeps_parent_coords() {
1814        let (b, b_info) = make_inline_tree();
1815        // Inline boxes push no transform, so the child's coordinates are
1816        // resolved in the block's content space: (40,15) falls inside `c` at
1817        // (30,10,50,20) even though `i`'s content origin is (10,10).
1818        let path = hit_test(&b, &b_info, 40.0, 15.0);
1819        assert_eq!(hit_dom_id(&path), Some(7));
1820        assert_eq!(path.len(), 3);
1821    }
1822
1823    #[test]
1824    fn hit_test_inside_inline_but_outside_child_hits_inline() {
1825        let (b, b_info) = make_inline_tree();
1826        // (12,12) is inside `i`'s line box but left of `c`; the inline
1827        // container itself is hit instead.
1828        let path = hit_test(&b, &b_info, 12.0, 12.0);
1829        assert_eq!(path.len(), 2);
1830        assert_eq!(hit_dom_id(&path), None);
1831    }
1832
1833    #[test]
1834    fn hit_test_outside_inline_line_boxes_falls_back_to_parent() {
1835        let (b, b_info) = make_inline_tree();
1836        // (40,50) is inside `b` but below `i`'s line box (10..30).
1837        let path = hit_test(&b, &b_info, 40.0, 50.0);
1838        assert_eq!(path.len(), 1);
1839        assert_eq!(hit_dom_id(&path), None);
1840    }
1841
1842    #[test]
1843    fn dispatch_pointer_to_inline_custom_keeps_parent_content_coords() {
1844        let node: Arc<RecordingNode> = Arc::new(RecordingNode::new(Rect {
1845            x: 0.0,
1846            y: 28.0,
1847            width: 120.0,
1848            height: 84.0,
1849        }));
1850        let custom_info = input_info(Arc::clone(&node) as Arc<dyn CustomNode>);
1851
1852        let mut inline_layout = LayoutNode::new(ui_layout::Style::default());
1853        inline_layout.layout_box = ui_layout::LayoutBox::InlineBox(ui_layout::InlineBox {
1854            box_model: box_model(10.0, 20.0, 160.0, 100.0, 160.0, 100.0),
1855            line_spans: vec![ui_layout::LineSpan {
1856                x_range: 0.0..160.0,
1857                line_pos: (10.0, 20.0),
1858                line_index: 0,
1859            }],
1860        });
1861        let mut block_layout = LayoutNode::new(ui_layout::Style::default());
1862        block_layout.layout_box =
1863            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 200.0, 200.0, 200.0));
1864
1865        let inline_info = container_info(false, None);
1866        let root_info = container_info(false, None);
1867
1868        node.popup_open.store(false, Ordering::Relaxed);
1869        let path = vec![
1870            HitItem {
1871                layout: &inline_layout,
1872                info: &custom_info,
1873            },
1874            HitItem {
1875                layout: &inline_layout,
1876                info: &inline_info,
1877            },
1878            HitItem {
1879                layout: &block_layout,
1880                info: &root_info,
1881            },
1882        ];
1883
1884        // Inline boxes contribute no content-origin offset, so the event is
1885        // delivered in the block's content space, not shifted by `i`'s (10,20)
1886        // content origin.
1887        dispatch_pointer(&path, PointerEvent::Down { x: 30.0, y: 35.0 });
1888        assert_eq!(node.events(), vec![PointerEvent::Down { x: 30.0, y: 35.0 }]);
1889    }
1890
1891    #[test]
1892    fn scroll_at_inside_inline_scrolls_child_in_parent_coords() {
1893        let mut c = LayoutNode::new(ui_layout::Style::default());
1894        c.layout_box =
1895            ui_layout::LayoutBox::BlockBox(box_model(30.0, 10.0, 50.0, 80.0, 50.0, 240.0));
1896        let mut b =
1897            LayoutNode::with_children(ui_layout::Style::default(), [inline_node(vec![c.into()])]);
1898        b.layout_box =
1899            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
1900
1901        let c_info = container_info(true, None);
1902        let mut i_info = container_info(false, None);
1903        i_info.children.push(c_info);
1904        let mut b_info = scrollable_info();
1905        b_info.children.push(i_info);
1906
1907        assert!(
1908            scroll_at(
1909                &b,
1910                &mut b_info,
1911                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1912                40.0,
1913                15.0,
1914                0.0,
1915                10.0
1916            )
1917            .is_some()
1918        );
1919        // The child under the cursor scrolls; the block parent does not.
1920        assert_eq!(scroll_offset_y_of(&b_info.children[0].children[0]), 10.0);
1921        assert_eq!(scroll_offset_y_of(&b_info), 0.0);
1922    }
1923
1924    fn sticky_box_model(
1925        x: f32,
1926        y: f32,
1927        width: f32,
1928        height: f32,
1929        children_width: f32,
1930        children_height: f32,
1931        edges: ui_layout::EdgeOption,
1932    ) -> ui_layout::BoxModel {
1933        let mut bm = box_model(x, y, width, height, children_width, children_height);
1934        bm.sticky_edges = Some(edges);
1935        bm
1936    }
1937
1938    #[test]
1939    fn hit_test_sticky_element_uses_visual_position() {
1940        // Scrollable container (scrolled 100px, viewport height 150) -> sticky child
1941        // naturally at y=80 with top:0. Sticky offset dy=20 => child content-space
1942        // y becomes 100..130, which appears at visual/screen y = 0..30 due to the
1943        // container scroll transform.
1944        let mut container_layout = LayoutNode::with_children(
1945            ui_layout::Style::default(),
1946            [LayoutNode::new({
1947                let mut s = ui_layout::Style::default();
1948                s.position.kind = Position::Sticky;
1949                s
1950            })],
1951        );
1952        container_layout.layout_box =
1953            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 150.0, 200.0, 400.0));
1954        let mut child_layout = LayoutNode::new({
1955            let mut s = ui_layout::Style::default();
1956            s.position.kind = Position::Sticky;
1957            s
1958        });
1959        child_layout.layout_box = ui_layout::LayoutBox::BlockBox(sticky_box_model(
1960            10.0,
1961            80.0,
1962            40.0,
1963            30.0,
1964            40.0,
1965            30.0,
1966            ui_layout::EdgeOption {
1967                top: Some(0.0),
1968                right: None,
1969                bottom: None,
1970                left: None,
1971            },
1972        ));
1973        container_layout.children[0] = ui_layout::LayoutChild::Node(Box::new(child_layout));
1974
1975        let mut root_layout =
1976            LayoutNode::with_children(ui_layout::Style::default(), [container_layout]);
1977        root_layout.layout_box =
1978            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 150.0, 200.0, 150.0));
1979
1980        let mut scroll_container_info = scrollable_info();
1981        set_vertical_scroll(&mut scroll_container_info, 100.0);
1982        let mut child_info = container_info_child(Some(77));
1983        if let NodeKind::Container { scroll_y, .. } = &mut child_info.kind {
1984            *scroll_y = false;
1985        }
1986        scroll_container_info.children = vec![child_info];
1987        let mut root_info = container_info(false, None);
1988        root_info.children = vec![scroll_container_info];
1989
1990        // Natural/layout position (y=80) is off-screen at scroll 100.
1991        let natural = hit_test(&root_layout, &root_info, 20.0, 90.0);
1992        assert_ne!(hit_dom_id(&natural), Some(77));
1993        // Visual stuck position appears near top of scrollport.
1994        let visual = hit_test(&root_layout, &root_info, 20.0, 15.0);
1995        assert_eq!(hit_dom_id(&visual), Some(77));
1996    }
1997
1998    fn container_info_child(dom_id: Option<u32>) -> InfoNode {
1999        InfoNode {
2000            kind: NodeKind::Container {
2001                scroll_x: false,
2002                scroll_y: false,
2003                scroll_offset_x: 0.0,
2004                scroll_offset_y: 0.0,
2005                style: ContainerStyle::default(),
2006                role: ContainerRole::Normal,
2007            },
2008            children: Vec::new(),
2009            dom_id,
2010        }
2011    }
2012
2013    #[test]
2014    fn scroll_at_sticky_element_uses_visual_position() {
2015        let mut container_layout = LayoutNode::with_children(
2016            ui_layout::Style::default(),
2017            [LayoutNode::new({
2018                let mut s = ui_layout::Style::default();
2019                s.position.kind = Position::Sticky;
2020                s
2021            })],
2022        );
2023        container_layout.layout_box =
2024            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 150.0, 200.0, 400.0));
2025        let mut child_layout = LayoutNode::new({
2026            let mut s = ui_layout::Style::default();
2027            s.position.kind = Position::Sticky;
2028            s
2029        });
2030        child_layout.layout_box = ui_layout::LayoutBox::BlockBox(sticky_box_model(
2031            10.0,
2032            80.0,
2033            40.0,
2034            30.0,
2035            40.0,
2036            120.0,
2037            ui_layout::EdgeOption {
2038                top: Some(0.0),
2039                right: None,
2040                bottom: None,
2041                left: None,
2042            },
2043        ));
2044        container_layout.children[0] = ui_layout::LayoutChild::Node(Box::new(child_layout));
2045
2046        let mut root_layout =
2047            LayoutNode::with_children(ui_layout::Style::default(), [container_layout]);
2048        root_layout.layout_box =
2049            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 150.0, 200.0, 150.0));
2050
2051        let mut scroll_container_info = scrollable_info();
2052        set_vertical_scroll(&mut scroll_container_info, 100.0);
2053        let child_info = container_info(true, Some(77));
2054        scroll_container_info.children = vec![child_info];
2055        let mut root_info = container_info(false, None);
2056        root_info.children = vec![scroll_container_info];
2057
2058        // Scroll at stuck visual position should target the sticky child.
2059        let scrolled = scroll_at(
2060            &root_layout,
2061            &mut root_info,
2062            (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
2063            20.0,
2064            15.0,
2065            0.0,
2066            10.0,
2067        );
2068        assert_eq!(scrolled, Some(77));
2069        assert_eq!(scroll_offset_y_of(&root_info.children[0].children[0]), 10.0);
2070        assert_eq!(scroll_offset_y_of(&root_info.children[0]), 100.0);
2071    }
2072
2073    fn transformed_container_info(
2074        transform: crate::engine::renderer_model::AffineTransform,
2075    ) -> InfoNode {
2076        InfoNode {
2077            kind: NodeKind::Container {
2078                scroll_x: false,
2079                scroll_y: true,
2080                scroll_offset_x: 0.0,
2081                scroll_offset_y: 0.0,
2082                style: ContainerStyle {
2083                    transform: Some(transform),
2084                    ..ContainerStyle::default()
2085                },
2086                role: ContainerRole::Normal,
2087            },
2088            children: Vec::new(),
2089            dom_id: None,
2090        }
2091    }
2092
2093    #[test]
2094    fn hit_test_and_scroll_translated_element_use_visual_position() {
2095        // Child block at (50,50,40,30) translated by (60,0) => visual (110,50).
2096        let mut root_layout = LayoutNode::with_children(
2097            ui_layout::Style::default(),
2098            [LayoutNode::new(ui_layout::Style::default())],
2099        );
2100        root_layout.layout_box =
2101            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 300.0, 200.0, 300.0, 200.0));
2102        let mut child_layout = LayoutNode::new(ui_layout::Style::default());
2103        child_layout.layout_box =
2104            ui_layout::LayoutBox::BlockBox(box_model(50.0, 50.0, 40.0, 30.0, 40.0, 120.0));
2105        root_layout.children[0] = ui_layout::LayoutChild::Node(Box::new(child_layout));
2106
2107        let mut root_info = container_info(false, None);
2108        let mut child_info = transformed_container_info(
2109            crate::engine::renderer_model::AffineTransform::translate(60.0, 0.0),
2110        );
2111        child_info.dom_id = Some(88);
2112        root_info.children.push(child_info);
2113
2114        // Natural position misses transformed visual placement.
2115        assert_ne!(
2116            hit_dom_id(&hit_test(&root_layout, &root_info, 60.0, 60.0)),
2117            Some(88)
2118        );
2119        // Visual position hits.
2120        assert_eq!(
2121            hit_dom_id(&hit_test(&root_layout, &root_info, 120.0, 60.0)),
2122            Some(88)
2123        );
2124
2125        let scrolled = scroll_at(
2126            &root_layout,
2127            &mut root_info,
2128            (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
2129            120.0,
2130            60.0,
2131            0.0,
2132            10.0,
2133        );
2134        assert_eq!(scrolled, Some(88));
2135        assert_eq!(scroll_offset_y_of(&root_info.children[0]), 10.0);
2136    }
2137
2138    #[test]
2139    fn dispatch_pointer_to_translated_custom_uses_visual_inverse() {
2140        let node: Arc<RecordingNode> = Arc::new(RecordingNode::new(Rect {
2141            x: 0.0,
2142            y: 0.0,
2143            width: 0.0,
2144            height: 0.0,
2145        }));
2146        node.popup_open.store(false, Ordering::Relaxed);
2147        let mut custom_info = input_info(Arc::clone(&node) as Arc<dyn CustomNode>);
2148        if let NodeKind::Custom { style, .. } = &mut custom_info.kind {
2149            style.transform = Some(crate::engine::renderer_model::AffineTransform::translate(
2150                60.0, 0.0,
2151            ));
2152        }
2153
2154        let mut root_layout = LayoutNode::with_children(
2155            ui_layout::Style::default(),
2156            [LayoutNode::new(ui_layout::Style::default())],
2157        );
2158        root_layout.layout_box =
2159            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 300.0, 200.0, 300.0, 200.0));
2160        let mut child_layout = LayoutNode::new(ui_layout::Style::default());
2161        child_layout.layout_box =
2162            ui_layout::LayoutBox::BlockBox(box_model(50.0, 50.0, 40.0, 30.0, 40.0, 30.0));
2163        root_layout.children[0] = ui_layout::LayoutChild::Node(Box::new(child_layout));
2164        let mut root_info = container_info(false, None);
2165        root_info.children.push(custom_info);
2166
2167        let path = hit_test(&root_layout, &root_info, 120.0, 60.0);
2168        assert!(hit_custom_node(&path).is_some());
2169        dispatch_pointer(&path, PointerEvent::Down { x: 120.0, y: 60.0 });
2170        // Visual (120,60) is local (10,10) inside the 40x30 box at content origin.
2171        assert_eq!(node.events(), vec![PointerEvent::Down { x: 10.0, y: 10.0 }]);
2172    }
2173
2174    #[test]
2175    fn hit_test_scaled_element_uses_visual_inverse() {
2176        // Box at (100,100,40,40), center (120,120). scale(2) makes visual (80,80)-(160,160).
2177        let mut root_layout = LayoutNode::with_children(
2178            ui_layout::Style::default(),
2179            [LayoutNode::new(ui_layout::Style::default())],
2180        );
2181        root_layout.layout_box =
2182            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 300.0, 300.0, 300.0, 300.0));
2183        let mut child_layout = LayoutNode::new(ui_layout::Style::default());
2184        child_layout.layout_box =
2185            ui_layout::LayoutBox::BlockBox(box_model(100.0, 100.0, 40.0, 40.0, 40.0, 40.0));
2186        root_layout.children[0] = ui_layout::LayoutChild::Node(Box::new(child_layout));
2187
2188        let mut root_info = container_info(false, None);
2189        let mut child_info = transformed_container_info(
2190            crate::engine::renderer_model::AffineTransform::scale(2.0, 2.0),
2191        );
2192        child_info.dom_id = Some(99);
2193        // make non-scrollable for simple hit
2194        if let NodeKind::Container { scroll_y, .. } = &mut child_info.kind {
2195            *scroll_y = false;
2196        }
2197        root_info.children.push(child_info);
2198
2199        // (90,120) is outside natural box (100..140) but inside scaled visual box.
2200        assert_eq!(
2201            hit_dom_id(&hit_test(&root_layout, &root_info, 90.0, 120.0)),
2202            Some(99)
2203        );
2204        // (170,120) is outside scaled visual box.
2205        assert_ne!(
2206            hit_dom_id(&hit_test(&root_layout, &root_info, 170.0, 120.0)),
2207            Some(99)
2208        );
2209    }
2210
2211    #[test]
2212    fn popup_hit_and_dispatch_on_translated_custom_use_visual_coords() {
2213        // Custom box at (50,50,40,30), translated +60x. Popup at local (0,30,40,20)
2214        // should be hittable/dispatchable at translated visual position.
2215        let node: Arc<RecordingNode> = Arc::new(RecordingNode::new(Rect {
2216            x: 0.0,
2217            y: 30.0,
2218            width: 40.0,
2219            height: 20.0,
2220        }));
2221        let mut custom_info = input_info(Arc::clone(&node) as Arc<dyn CustomNode>);
2222        if let NodeKind::Custom { style, .. } = &mut custom_info.kind {
2223            style.transform = Some(crate::engine::renderer_model::AffineTransform::translate(
2224                60.0, 0.0,
2225            ));
2226        }
2227
2228        let mut root_layout = LayoutNode::with_children(
2229            ui_layout::Style::default(),
2230            [LayoutNode::new(ui_layout::Style::default())],
2231        );
2232        root_layout.layout_box =
2233            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 300.0, 200.0, 300.0, 200.0));
2234        let mut child_layout = LayoutNode::new(ui_layout::Style::default());
2235        child_layout.layout_box =
2236            ui_layout::LayoutBox::BlockBox(box_model(50.0, 50.0, 40.0, 30.0, 40.0, 30.0));
2237        root_layout.children[0] = ui_layout::LayoutChild::Node(Box::new(child_layout));
2238        let mut root_info = container_info(false, None);
2239        root_info.children.push(custom_info);
2240
2241        // Visual popup area: local popup (0,30)-(40,50) -> box local (50,80)-(90,100)
2242        // -> translated to (110,80)-(150,100).
2243        let path = hit_test(&root_layout, &root_info, 120.0, 90.0);
2244        assert!(hit_custom_node(&path).is_some());
2245        dispatch_pointer(&path, PointerEvent::Down { x: 120.0, y: 90.0 });
2246        assert_eq!(
2247            node.popup_events(),
2248            vec![PointerEvent::Down { x: 10.0, y: 10.0 }]
2249        );
2250        assert!(node.events().is_empty());
2251    }
2252
2253    #[test]
2254    fn popup_hit_and_dispatch_on_inline_custom_node() {
2255        let node: Arc<RecordingNode> = Arc::new(RecordingNode::new(Rect {
2256            x: 0.0,
2257            y: 30.0,
2258            width: 100.0,
2259            height: 60.0,
2260        }));
2261        let custom_info = input_info(Arc::clone(&node) as Arc<dyn CustomNode>);
2262
2263        let mut root_layout = LayoutNode::with_children(
2264            ui_layout::Style::default(),
2265            [LayoutNode::new(ui_layout::Style::default())],
2266        );
2267        root_layout.layout_box =
2268            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 400.0, 400.0, 400.0, 400.0));
2269
2270        let mut inline_custom_layout = LayoutNode::new(ui_layout::Style::default());
2271        inline_custom_layout.layout_box = ui_layout::LayoutBox::InlineBox(ui_layout::InlineBox {
2272            box_model: box_model(40.0, 50.0, 100.0, 30.0, 100.0, 30.0),
2273            line_spans: vec![ui_layout::LineSpan {
2274                x_range: 40.0..140.0,
2275                line_pos: (40.0, 50.0),
2276                line_index: 0,
2277            }],
2278        });
2279        root_layout.children[0] = ui_layout::LayoutChild::Node(Box::new(inline_custom_layout));
2280
2281        let mut root_info = container_info(false, None);
2282        root_info.children.push(custom_info);
2283
2284        // Global (60, 95): inside popup at (40, 80)-(140, 140).
2285        // Custom content local: (60-40, 95-50) = (20, 45).
2286        // Popup local: (20, 45-30) = (20, 15).
2287        let path = hit_test(&root_layout, &root_info, 60.0, 95.0);
2288        assert!(hit_custom_node(&path).is_some());
2289        assert!(!dismiss_open_popups(&root_info, &path, 60.0, 95.0));
2290        dispatch_pointer(&path, PointerEvent::Down { x: 60.0, y: 95.0 });
2291        assert_eq!(
2292            node.popup_events(),
2293            vec![PointerEvent::Down { x: 20.0, y: 15.0 }]
2294        );
2295        assert!(node.events().is_empty());
2296    }
2297
2298    #[test]
2299    fn popup_escaping_ancestor_box_receives_dispatch() {
2300        let (root_layout, root_info, node) = make_tree(0.0);
2301        // Container box ends at y=120, popup reaches y=132.
2302        // Click at (20, 125): inside popup, outside container.
2303        let path = hit_test(&root_layout, &root_info, 20.0, 125.0);
2304        assert!(hit_custom_node(&path).is_some());
2305        assert!(!dismiss_open_popups(&root_info, &path, 20.0, 125.0));
2306
2307        // Global (20, 125) -> local (20-15, 125-20) = (5, 105)
2308        // -> inside popup (y in 28..112) -> popup local (5, 105-28=77).
2309        dispatch_pointer(&path, PointerEvent::Down { x: 20.0, y: 125.0 });
2310        assert_eq!(
2311            node.popup_events(),
2312            vec![PointerEvent::Down { x: 5.0, y: 77.0 }]
2313        );
2314        assert!(node.events().is_empty());
2315    }
2316}