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::{InfoNode, NodeKind, TextStyle};
8use super::ui::PointerEvent;
9use super::ui::custom_node::CustomNode;
10use super::ui::input_text_types::InputTextEvent;
11use ui_layout::{LayoutNode, Position};
12/// ヒットしたノード情報
13#[derive(Clone, Debug)]
14pub struct HitItem<'a> {
15    pub layout: &'a LayoutNode,
16    pub info: &'a InfoNode,
17}
18
19/// ヒットパス(子→親の順)
20pub type HitPath<'a> = Vec<HitItem<'a>>;
21
22/// Returns the innermost custom node on a hit path, if any.
23///
24/// The hit path is ordered child→parent, so the first `Custom` node found is
25/// the deepest node under the pointer.
26pub fn hit_custom_node<'a>(path: &'a HitPath<'a>) -> Option<&'a Arc<dyn CustomNode>> {
27    path.iter().find_map(|hit| match &hit.info.kind {
28        NodeKind::Custom { node, .. } => Some(node),
29        _ => None,
30    })
31}
32
33/// Returns the innermost DOM node id on a hit path, if any.
34///
35/// The hit path is ordered child→parent, so the first node carrying a
36/// [`InfoNode::dom_id`] is the deepest DOM-backed element under the pointer.
37pub fn hit_dom_id(path: &HitPath<'_>) -> Option<u32> {
38    path.iter().find_map(|hit| hit.info.dom_id)
39}
40
41/// Converts page-space pointer coordinates into the local content-box space of
42/// the custom node at `path[target]`.
43///
44/// The hit path is ordered child→parent, so each entry's `content_box` origin
45/// is expressed in its *parent's* coordinate space. Mirroring
46/// [`hit_test_inner`], the chain is walked outermost→innermost, subtracting
47/// each level's content-box origin and adding its scroll offsets.
48///
49/// Entries that share their `LayoutNode` with the next outer entry (the
50/// synthetic items `hit_test_inner` emits for an inline custom object) have
51/// no coordinate frame of their own: the object was tested in its parent's
52/// local coordinates, so they contribute no offset.
53fn local_pointer_coords(path: &HitPath<'_>, target: usize, x: f32, y: f32) -> (f32, f32) {
54    let mut lx = x;
55    let mut ly = y;
56    for (offset, hit) in path.iter().rev().take(path.len() - target).enumerate() {
57        let index = path.len() - 1 - offset;
58        let shares_parent_layout =
59            index + 1 < path.len() && std::ptr::eq(hit.layout, path[index + 1].layout);
60        // Inline Container boxes live in the parent's coordinate space and
61        // push no transform (mirroring `push_box_model`), so they contribute
62        // no offset of their own. Inline Custom nodes always push a transform,
63        // so they must apply the offset.
64        let is_inline_container =
65            matches!(hit.layout.layout_box, ui_layout::LayoutBox::InlineBox(_))
66                && matches!(&hit.info.kind, NodeKind::Container { .. });
67        if shares_parent_layout || is_inline_container {
68            continue;
69        }
70        let (cx, cy) = hit
71            .layout
72            .layout_box
73            .iter()
74            .next()
75            .map_or((0.0, 0.0), |b| (b.content_box.x, b.content_box.y));
76        let (sx, sy) = hit.info.kind.scroll_offsets();
77        lx += sx - cx;
78        ly += sy - cy;
79    }
80    (lx, ly)
81}
82
83/// Dispatches a pointer event to the innermost custom node on the hit path.
84///
85/// Event coordinates are translated from page space into the node's local
86/// content-box space before delivery.
87pub fn dispatch_pointer(path: &HitPath<'_>, event: PointerEvent) -> bool {
88    for (target, hit) in path.iter().enumerate() {
89        if let NodeKind::Custom {
90            node,
91            text_style,
92            text_flow_style,
93            ..
94        } = &hit.info.kind
95        {
96            let (px, py) = match event {
97                PointerEvent::Move { x, y } => (x, y),
98                PointerEvent::Down { x, y } => (x, y),
99                PointerEvent::Up { x, y } => (x, y),
100                PointerEvent::Leave => (0.0, 0.0), // no coordinates for Leave
101            };
102            let (lx, ly) = local_pointer_coords(path, target, px, py);
103            let local_event = match event {
104                PointerEvent::Move { .. } => PointerEvent::Move { x: lx, y: ly },
105                PointerEvent::Down { .. } => PointerEvent::Down { x: lx, y: ly },
106                PointerEvent::Up { .. } => PointerEvent::Up { x: lx, y: ly },
107                PointerEvent::Leave => PointerEvent::Leave,
108            };
109
110            // An open popup intercepts pointer events over it. Its rect is in
111            // the same content-box space as the local coordinates.
112            if let Some(popup) = node.popup(text_style, text_flow_style) {
113                let in_popup = lx >= popup.rect.x
114                    && ly >= popup.rect.y
115                    && lx <= popup.rect.x + popup.rect.width
116                    && ly <= popup.rect.y + popup.rect.height;
117                // Popup events are expressed relative to the popup's own
118                // top-left corner (`popup.rect` origin).
119                let popup_event = match local_event {
120                    PointerEvent::Move { x, y } => PointerEvent::Move {
121                        x: x - popup.rect.x,
122                        y: y - popup.rect.y,
123                    },
124                    PointerEvent::Down { x, y } => PointerEvent::Down {
125                        x: x - popup.rect.x,
126                        y: y - popup.rect.y,
127                    },
128                    PointerEvent::Up { x, y } => PointerEvent::Up {
129                        x: x - popup.rect.x,
130                        y: y - popup.rect.y,
131                    },
132                    PointerEvent::Leave => PointerEvent::Leave,
133                };
134
135                if in_popup {
136                    return node.on_popup_pointer_event(popup_event);
137                }
138            }
139            return node.on_pointer_event(local_event);
140        }
141    }
142    false
143}
144
145/// Dismisses every open popup whose owner is not under the pointer press.
146///
147/// Implements top-layer dismissal: a press whose hit path already contains a
148/// popup's owner is that owner's responsibility (the event is routed to the
149/// popup, or to the owning box which closes it), while every other open popup
150/// is closed. Returns whether any popup was dismissed.
151pub fn dismiss_open_popups(info: &InfoNode, path: &HitPath<'_>) -> bool {
152    let mut dismissed = false;
153    dismiss_open_popups_inner(info, path, &mut dismissed);
154    dismissed
155}
156
157fn dismiss_open_popups_inner(info: &InfoNode, path: &HitPath<'_>, dismissed: &mut bool) {
158    if let NodeKind::Custom { node, .. } = &info.kind
159        && node.popup(&TextStyle::default(), &TextFlowStyle::default()).is_some()
160        && !path.iter().any(|hit| {
161            matches!(&hit.info.kind, NodeKind::Custom { node: owner, .. } if Arc::ptr_eq(node, owner))
162        })
163    {
164        node.dismiss_popup();
165        *dismissed = true;
166    }
167    for child in &info.children {
168        dismiss_open_popups_inner(child, path, dismissed);
169    }
170}
171
172/// Updates the hover state of custom nodes after a pointer move.
173///
174/// Clears hover from the previously hovered node (if different) and sets it on
175/// the node under the pointer. Returns whether the hover target changed.
176pub fn update_hover(path: &HitPath<'_>, previous: Option<&Arc<dyn CustomNode>>) -> bool {
177    let current = hit_custom_node(path);
178    match (previous, current) {
179        (Some(prev), Some(curr)) if Arc::ptr_eq(prev, curr) => false,
180        (Some(prev), _) => {
181            prev.set_hovered(false);
182            if let Some(curr) = current {
183                curr.set_hovered(true);
184            }
185            true
186        }
187        (None, Some(curr)) => {
188            curr.set_hovered(true);
189            true
190        }
191        (None, None) => false,
192    }
193}
194
195pub fn hit_test<'a>(layout: &'a LayoutNode, info: &'a InfoNode, x: f32, y: f32) -> HitPath<'a> {
196    // Open popups are top-layer overlays: they render above every box and
197    // escape all ancestor clips, so they are tested first and shadow the box
198    // tree at their position.
199    if let Some(path) = hit_test_popup(layout, info, x, y) {
200        return path;
201    }
202    hit_test_inner(layout, info, x, y, (0.0, 0.0))
203}
204
205fn hit_test_inner<'a>(
206    layout: &'a LayoutNode,
207    info: &'a InfoNode,
208    mut x: f32,
209    mut y: f32,
210    accumulated_scroll: (f32, f32),
211) -> HitPath<'a> {
212    // layout_boxes が空なら何もヒットしない
213    if layout.layout_box.is_empty() {
214        return Vec::new();
215    }
216
217    let is_inline = matches!(layout.layout_box, ui_layout::LayoutBox::InlineBox(_));
218
219    let is_fixed = layout.style.position.kind == Position::Fixed;
220    if is_fixed {
221        x -= accumulated_scroll.0;
222        y -= accumulated_scroll.1;
223    }
224
225    let own_scroll = info.kind.scroll_offsets();
226    let child_scroll = if is_fixed {
227        own_scroll
228    } else {
229        (
230            accumulated_scroll.0 + own_scroll.0,
231            accumulated_scroll.1 + own_scroll.1,
232        )
233    };
234
235    for box_model in layout
236        .layout_box
237        .iter()
238        .collect::<Vec<_>>()
239        .into_iter()
240        .rev()
241    {
242        // 後ろの box が前面
243        let rect = box_model.padding_box;
244
245        // 1. rect 外なら次の box へ
246        if x < rect.x || y < rect.y || x > rect.x + rect.width || y > rect.y + rect.height {
247            continue;
248        }
249
250        // 2. ローカル座標に変換(スクロールオフセット考慮)。
251        // Inline Container boxes live in the parent's coordinate space and
252        // push no transform (mirroring `push_box_model`), so their children
253        // keep the incoming coordinates untouched. Inline Custom nodes always
254        // push a transform, so they apply the content-box offset.
255        let is_inline_container = is_inline && matches!(&info.kind, NodeKind::Container { .. });
256        let (local_x, local_y) = if is_inline_container {
257            (x, y)
258        } else {
259            (
260                x - box_model.content_box.x + own_scroll.0,
261                y - box_model.content_box.y + own_scroll.1,
262            )
263        };
264
265        // 3. 子ノードを前面から探索
266        for (child_layout, child_info) in layout.children.iter().zip(&info.children).rev() {
267            if let Some(child_node) = child_layout.node() {
268                let mut path =
269                    hit_test_inner(child_node, child_info, local_x, local_y, child_scroll);
270                if !path.is_empty() {
271                    // 子がヒット → 自分を末尾に追加
272                    path.push(HitItem { layout, info });
273                    return path;
274                }
275            } else if let Some(result) = child_layout.custom_result()
276                && result.spans.iter().any(|span| {
277                    // `line_pos` positions the line in the parent's coordinate
278                    // space (where inline content is laid out); `x_range` is
279                    // only used for its width.
280                    local_x >= span.line_pos.0
281                        && local_x <= span.line_pos.0 + span.width()
282                        && local_y >= span.line_pos.1
283                        && local_y <= span.line_pos.1 + result.box_model.content_box.height
284                })
285            {
286                return vec![
287                    HitItem {
288                        layout,
289                        info: child_info,
290                    },
291                    HitItem { layout, info },
292                ];
293            }
294        }
295
296        // 4. 子ノードに当たらなければこの box がヒット
297        return vec![HitItem { layout, info }];
298    }
299
300    // どの box にもヒットしなかった
301    Vec::new()
302}
303
304/// Returns the hit path (child→parent) of the topmost open popup containing
305/// `(x, y)`, or `None`.
306///
307/// Popups are top-layer overlays: they render above every box and escape all
308/// ancestor clips, so they are hit-tested independently of box containment.
309/// The scan mirrors [`hit_test_inner`]'s coordinate descent but skips the
310/// padding-box checks; when several popups overlap, the one later in tree
311/// order wins because it renders on top.
312fn hit_test_popup<'a>(
313    layout: &'a LayoutNode,
314    info: &'a InfoNode,
315    x: f32,
316    y: f32,
317) -> Option<HitPath<'a>> {
318    let mut best: Option<HitPath<'a>> = None;
319    let mut prefix: HitPath<'a> = Vec::new();
320    hit_test_popup_inner(layout, info, x, y, (0.0, 0.0), &mut prefix, &mut best);
321    best
322}
323
324/// Recursive top-layer popup scan. `prefix` holds the root→current hit path;
325/// matches recorded later (tree order) overwrite earlier ones.
326fn hit_test_popup_inner<'a>(
327    layout: &'a LayoutNode,
328    info: &'a InfoNode,
329    mut x: f32,
330    mut y: f32,
331    accumulated_scroll: (f32, f32),
332    prefix: &mut HitPath<'a>,
333    best: &mut Option<HitPath<'a>>,
334) {
335    if layout.layout_box.is_empty() {
336        return;
337    }
338
339    let is_inline = matches!(layout.layout_box, ui_layout::LayoutBox::InlineBox(_));
340
341    let is_fixed = layout.style.position.kind == Position::Fixed;
342    if is_fixed {
343        x -= accumulated_scroll.0;
344        y -= accumulated_scroll.1;
345    }
346
347    let own_scroll = info.kind.scroll_offsets();
348    let child_scroll = if is_fixed {
349        own_scroll
350    } else {
351        (
352            accumulated_scroll.0 + own_scroll.0,
353            accumulated_scroll.1 + own_scroll.1,
354        )
355    };
356
357    // The popup rect lives in the node's content-box space, the same space as
358    // `draw_sized`, which is anchored to the first layout box. Inline
359    // Container boxes push no transform, so their content stays in the
360    // parent's coordinate space. Inline Custom nodes always push a transform.
361    let is_inline_container = is_inline && matches!(&info.kind, NodeKind::Container { .. });
362    let (local_x, local_y) = if is_inline_container {
363        (x, y)
364    } else {
365        layout.layout_box.iter().next().map_or((x, y), |b| {
366            (
367                x - b.content_box.x + own_scroll.0,
368                y - b.content_box.y + own_scroll.1,
369            )
370        })
371    };
372
373    if let NodeKind::Custom {
374        node,
375        text_style,
376        text_flow_style,
377        ..
378    } = &info.kind
379        && let Some(popup) = node.popup(text_style, text_flow_style)
380        && local_x >= popup.rect.x
381        && local_y >= popup.rect.y
382        && local_x <= popup.rect.x + popup.rect.width
383        && local_y <= popup.rect.y + popup.rect.height
384    {
385        let mut path = prefix.clone();
386        path.push(HitItem { layout, info });
387        path.reverse();
388        *best = Some(path);
389    }
390
391    prefix.push(HitItem { layout, info });
392    for (child_layout, child_info) in layout.children.iter().zip(&info.children) {
393        if let Some(child_node) = child_layout.node() {
394            hit_test_popup_inner(
395                child_node,
396                child_info,
397                local_x,
398                local_y,
399                child_scroll,
400                prefix,
401                best,
402            );
403        }
404    }
405    prefix.pop();
406}
407
408/// Marker returned from [`scroll_at`] when a container scrolled but carries no
409/// snapshot dom id (so no `scroll` event can be dispatched to it). Callers can
410/// treat any `Some(..)` as "scrolled" and use this value to skip dispatch.
411pub const NO_SCROLL_DOM_ID: u32 = u32::MAX;
412
413/// Scrolls the innermost scrollable container under `(x, y)` by `(dx, dy)`.
414///
415/// Mirrors [`hit_test`]: boxes are tested front-to-back and children are
416/// visited before the node itself, so the innermost container wins. Only
417/// nodes whose [`NodeKind::Container`] / [`NodeKind::Custom`] flags enable
418/// scrolling for an axis are scrolled, clamped to the scrollable range
419/// (`children_box` extent minus the visible `content_box`).
420///
421/// Returns `Some(dom_id)` when any scroll offset actually changed, where
422/// `dom_id` names the scrollable container that absorbed the scroll. The value
423/// is [`NO_SCROLL_DOM_ID`] when the scrolled container had no snapshot dom id
424/// (used to trigger a redraw without dispatching a `scroll` event); `None` when
425/// nothing scrolled, so a caller can chain the wheel event to an ancestor
426/// (e.g. the root).
427pub fn scroll_at(
428    layout: &LayoutNode,
429    info: &mut InfoNode,
430    viewport: (f32, f32),
431    x: f32,
432    y: f32,
433    dx: f32,
434    dy: f32,
435) -> Option<u32> {
436    scroll_at_inner(layout, info, viewport, x, y, dx, dy, (0.0, 0.0))
437}
438
439#[allow(clippy::too_many_arguments)]
440fn scroll_at_inner(
441    layout: &LayoutNode,
442    info: &mut InfoNode,
443    viewport: (f32, f32),
444    mut x: f32,
445    mut y: f32,
446    dx: f32,
447    dy: f32,
448    accumulated_scroll: (f32, f32),
449) -> Option<u32> {
450    if layout.layout_box.is_empty() {
451        return None;
452    }
453
454    let is_inline = matches!(layout.layout_box, ui_layout::LayoutBox::InlineBox(_));
455
456    let is_fixed = layout.style.position.kind == Position::Fixed;
457    if is_fixed {
458        x -= accumulated_scroll.0;
459        y -= accumulated_scroll.1;
460    }
461
462    let own_scroll = info.kind.scroll_offsets();
463    let child_scroll = if is_fixed {
464        own_scroll
465    } else {
466        (
467            accumulated_scroll.0 + own_scroll.0,
468            accumulated_scroll.1 + own_scroll.1,
469        )
470    };
471
472    for box_model in layout
473        .layout_box
474        .iter()
475        .collect::<Vec<_>>()
476        .into_iter()
477        .rev()
478    {
479        let rect = box_model.padding_box;
480        if x < rect.x || y < rect.y || x > rect.x + rect.width || y > rect.y + rect.height {
481            continue;
482        }
483
484        // Inline Container boxes live in the parent's coordinate space and push
485        // no transform (mirroring `push_box_model`), so their children keep
486        // the incoming coordinates untouched. Inline Custom nodes always push
487        // a transform, so they apply the content-box offset.
488        let is_inline_container = is_inline && matches!(&info.kind, NodeKind::Container { .. });
489        let (local_x, local_y) = if is_inline_container {
490            (x, y)
491        } else {
492            (
493                x - box_model.content_box.x + own_scroll.0,
494                y - box_model.content_box.y + own_scroll.1,
495            )
496        };
497
498        for (child_layout, child_info) in layout.children.iter().zip(&mut info.children).rev() {
499            if let Some(child_node) = child_layout.node()
500                && let Some(scrolled_id) = scroll_at_inner(
501                    child_node,
502                    child_info,
503                    viewport,
504                    local_x,
505                    local_y,
506                    dx,
507                    dy,
508                    child_scroll,
509                )
510            {
511                return Some(scrolled_id);
512            }
513        }
514
515        let scrolled = match &mut info.kind {
516            NodeKind::Container {
517                scroll_x,
518                scroll_y,
519                scroll_offset_x,
520                scroll_offset_y,
521                ..
522            }
523            | NodeKind::Custom {
524                scroll_x,
525                scroll_y,
526                scroll_offset_x,
527                scroll_offset_y,
528                ..
529            } => {
530                let mut changed = false;
531
532                let (vw, vh) = viewport;
533
534                if *scroll_y {
535                    let max_scroll = (box_model.children_box.height
536                        - box_model.content_box.height.min(vh))
537                    .max(0.0);
538                    let next = (*scroll_offset_y + dy).clamp(0.0, max_scroll);
539                    if (next - *scroll_offset_y).abs() > f32::EPSILON {
540                        changed = true;
541                    }
542                    *scroll_offset_y = next;
543                }
544                if *scroll_x {
545                    let max_scroll = (box_model.children_box.width
546                        - box_model.content_box.width.min(vw))
547                    .max(0.0);
548                    let next = (*scroll_offset_x + dx).clamp(0.0, max_scroll);
549                    if (next - *scroll_offset_x).abs() > f32::EPSILON {
550                        changed = true;
551                    }
552                    *scroll_offset_x = next;
553                }
554                changed
555            }
556            _ => false,
557        };
558        if scrolled {
559            return Some(info.dom_id.unwrap_or(NO_SCROLL_DOM_ID));
560        }
561    }
562
563    None
564}
565
566/// Focuses `target` and clears focus from every other text input.
567///
568/// Returns whether a text input received focus.
569pub fn focus_text_input(info: &InfoNode, target: Option<&Arc<dyn CustomNode>>) -> bool {
570    let mut focused = false;
571    if let NodeKind::Custom { node, .. } = &info.kind
572        && node.accepts_text_input()
573    {
574        let is_target = target.is_some_and(|target| Arc::ptr_eq(node, target));
575        node.set_focused(is_target);
576        focused |= is_target;
577    }
578    for child in &info.children {
579        focused |= focus_text_input(child, target);
580    }
581    focused
582}
583
584/// Sends an editing event to the focused text input, if one exists.
585pub fn dispatch_text_input(info: &InfoNode, event: InputTextEvent) -> bool {
586    if let NodeKind::Custom { node, .. } = &info.kind
587        && node.accepts_text_input()
588        && node.is_focused()
589    {
590        return node.handle_text_input(event);
591    }
592    for child in &info.children {
593        if dispatch_text_input(child, event.clone()) {
594            return true;
595        }
596    }
597    false
598}
599
600/// Returns whether the focused text input has an active IME composition.
601pub fn focused_text_input_is_composing(info: &InfoNode) -> bool {
602    if let NodeKind::Custom { node, .. } = &info.kind
603        && node.accepts_text_input()
604        && node.is_focused()
605    {
606        return node.is_composing();
607    }
608    info.children.iter().any(focused_text_input_is_composing)
609}
610
611/// Returns whether any custom node in the tree reports a pending repaint.
612///
613/// Consumes the repaint flags of the nodes it visits, so callers should only
614/// invoke this once per frame, right before deciding whether to redraw.
615pub fn any_custom_node_needs_repaint(info: &InfoNode) -> bool {
616    if let NodeKind::Custom { node, .. } = &info.kind
617        && node.needs_repaint()
618    {
619        return true;
620    }
621    info.children.iter().any(any_custom_node_needs_repaint)
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use crate::engine::bridge::text::{FallbackTextMeasurer, TextMeasurer};
628    use crate::engine::layouter::types::{Color, ContainerRole, ContainerStyle, TextStyle};
629    use crate::engine::renderer_model::{DrawCommand, Rect};
630    use crate::engine::ui::button::ButtonComponent;
631    use crate::engine::ui::input_text::InputTextComponent;
632    use crate::engine::ui::input_text_types::InputTextEvent;
633    use crate::engine::ui::{ContentSize, Popup};
634    use std::sync::Arc;
635    use std::sync::Mutex;
636    use std::sync::atomic::{AtomicBool, Ordering};
637    use ui_layout::{LayoutChild, Style};
638
639    const VIEWPORT_WIDTH: f32 = 800.0;
640    const VIEWPORT_HEIGHT: f32 = 600.0;
641
642    fn input_info(node: Arc<dyn CustomNode>) -> InfoNode {
643        InfoNode {
644            kind: NodeKind::Custom {
645                node,
646                scroll_x: false,
647                scroll_y: false,
648                scroll_offset_x: 0.0,
649                scroll_offset_y: 0.0,
650                style: ContainerStyle::default(),
651                layout_style: ui_layout::Style::default(),
652                text_style: TextStyle::default(),
653                text_flow_style: TextFlowStyle::default(),
654            },
655            children: Vec::new(),
656            dom_id: None,
657        }
658    }
659
660    #[test]
661    fn focus_and_dispatch_target_one_input() {
662        let measurer: Arc<dyn TextMeasurer> = Arc::new(FallbackTextMeasurer);
663        let first: Arc<dyn CustomNode> =
664            Arc::new(InputTextComponent::new("", "", Arc::clone(&measurer)));
665        let second: Arc<dyn CustomNode> = Arc::new(InputTextComponent::new("", "", measurer));
666        let root = InfoNode {
667            kind: NodeKind::LineBreak,
668            children: vec![
669                input_info(Arc::clone(&first)),
670                input_info(Arc::clone(&second)),
671            ],
672            dom_id: None,
673        };
674
675        assert!(focus_text_input(&root, Some(&second)));
676        assert!(!first.is_focused());
677        assert!(second.is_focused());
678        assert!(dispatch_text_input(
679            &root,
680            InputTextEvent::Commit("日本".into())
681        ));
682    }
683
684    #[test]
685    fn hit_custom_node_finds_innermost_custom() {
686        let node: Arc<dyn CustomNode> = Arc::new(InputTextComponent::new(
687            "",
688            "",
689            Arc::new(FallbackTextMeasurer),
690        ));
691        let info = input_info(Arc::clone(&node));
692        let layout = LayoutNode::new(ui_layout::Style::default());
693        let path = vec![
694            HitItem {
695                layout: &layout,
696                info: &info,
697            },
698            HitItem {
699                layout: &layout,
700                info: &info,
701            },
702        ];
703        assert!(Arc::ptr_eq(hit_custom_node(&path).unwrap(), &node));
704    }
705
706    #[test]
707    fn update_hover_switches_target() {
708        let measurer: Arc<dyn TextMeasurer> = Arc::new(FallbackTextMeasurer);
709        let a: Arc<dyn CustomNode> = Arc::new(ButtonComponent::new(
710            "A",
711            Color(0, 0, 0, 255),
712            Color(255, 255, 255, 255),
713            Arc::clone(&measurer),
714        ));
715        let b: Arc<dyn CustomNode> = Arc::new(ButtonComponent::new(
716            "B",
717            Color(0, 0, 0, 255),
718            Color(255, 255, 255, 255),
719            measurer,
720        ));
721        let info_a = input_info(Arc::clone(&a));
722        let info_b = input_info(Arc::clone(&b));
723        let layout = LayoutNode::new(ui_layout::Style::default());
724        let path_a = vec![HitItem {
725            layout: &layout,
726            info: &info_a,
727        }];
728        let path_b = vec![HitItem {
729            layout: &layout,
730            info: &info_b,
731        }];
732
733        assert!(update_hover(&path_a, None));
734        assert!(a.is_hovered());
735        assert!(!b.is_hovered());
736
737        assert!(update_hover(&path_b, Some(&a)));
738        assert!(!a.is_hovered());
739        assert!(b.is_hovered());
740
741        assert!(!update_hover(&path_b, Some(&b)));
742    }
743
744    #[test]
745    fn any_custom_node_needs_repaint_tracks_dirty_nodes() {
746        let measurer: Arc<dyn TextMeasurer> = Arc::new(FallbackTextMeasurer);
747        let a: Arc<dyn CustomNode> = Arc::new(ButtonComponent::new(
748            "A",
749            Color(0, 0, 0, 255),
750            Color(255, 255, 255, 255),
751            Arc::clone(&measurer),
752        ));
753        let info_a = input_info(Arc::clone(&a));
754
755        // Fresh nodes are dirty (initial paint).
756        assert!(any_custom_node_needs_repaint(&info_a));
757        assert!(!any_custom_node_needs_repaint(&info_a));
758
759        // A pointer event that changes visual state marks it dirty again.
760        a.on_pointer_event(PointerEvent::Down { x: 0.0, y: 0.0 });
761        assert!(any_custom_node_needs_repaint(&info_a));
762        assert!(!any_custom_node_needs_repaint(&info_a));
763    }
764
765    fn box_model(
766        x: f32,
767        y: f32,
768        width: f32,
769        height: f32,
770        children_width: f32,
771        children_height: f32,
772    ) -> ui_layout::BoxModel {
773        let rect = ui_layout::Rect {
774            x,
775            y,
776            width,
777            height,
778        };
779        ui_layout::BoxModel {
780            sticky_edges: None,
781            border_box: rect,
782            padding_box: rect,
783            content_box: rect,
784            children_box: ui_layout::Rect {
785                x,
786                y,
787                width: children_width,
788                height: children_height,
789            },
790        }
791    }
792
793    fn scrollable_info() -> InfoNode {
794        InfoNode {
795            kind: NodeKind::Container {
796                scroll_x: false,
797                scroll_y: true,
798                scroll_offset_x: 0.0,
799                scroll_offset_y: 0.0,
800                style: ContainerStyle::default(),
801                role: crate::engine::layouter::types::ContainerRole::Normal,
802            },
803            children: Vec::new(),
804            dom_id: None,
805        }
806    }
807
808    fn container_info(scroll_y: bool, dom_id: Option<u32>) -> InfoNode {
809        InfoNode {
810            kind: NodeKind::Container {
811                scroll_x: false,
812                scroll_y,
813                scroll_offset_x: 0.0,
814                scroll_offset_y: 0.0,
815                style: ContainerStyle::default(),
816                role: crate::engine::layouter::types::ContainerRole::Normal,
817            },
818            children: Vec::new(),
819            dom_id,
820        }
821    }
822
823    /// An inline container laid out in its parent's content space: its box
824    /// model and line spans use absolute coordinates (mirroring how the flow
825    /// engine positions inline content).
826    fn inline_node(children: Vec<LayoutChild>) -> LayoutNode {
827        let mut node = LayoutNode::with_children(ui_layout::Style::default(), children);
828        node.layout_box = ui_layout::LayoutBox::InlineBox(ui_layout::InlineBox {
829            box_model: box_model(10.0, 10.0, 200.0, 20.0, 200.0, 20.0),
830            line_spans: vec![ui_layout::LineSpan {
831                x_range: 0.0..200.0,
832                line_pos: (10.0, 10.0),
833                line_index: 0,
834            }],
835        });
836        node
837    }
838
839    fn scroll_offset_y_of(info: &InfoNode) -> f32 {
840        let NodeKind::Container {
841            scroll_offset_y, ..
842        } = &info.kind
843        else {
844            panic!("expected container");
845        };
846        *scroll_offset_y
847    }
848
849    fn set_vertical_scroll(info: &mut InfoNode, offset: f32) {
850        let NodeKind::Container {
851            scroll_offset_y, ..
852        } = &mut info.kind
853        else {
854            panic!("expected container");
855        };
856        *scroll_offset_y = offset;
857    }
858
859    fn fixed_child_layout(children_height: f32) -> LayoutNode {
860        let mut style = ui_layout::Style::default();
861        style.position.kind = Position::Fixed;
862        let mut child = LayoutNode::new(style);
863        child.layout_box = ui_layout::LayoutBox::BlockBox(box_model(
864            10.0,
865            10.0,
866            30.0,
867            20.0,
868            30.0,
869            children_height,
870        ));
871        child
872    }
873
874    #[test]
875    fn hit_test_fixed_child_ignores_ancestor_scroll() {
876        let mut layout =
877            LayoutNode::with_children(ui_layout::Style::default(), [fixed_child_layout(20.0)]);
878        layout.layout_box =
879            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
880
881        let mut info = scrollable_info();
882        set_vertical_scroll(&mut info, 50.0);
883        let mut fixed_info = scrollable_info();
884        fixed_info.dom_id = Some(42);
885        info.children.push(fixed_info);
886
887        let path = hit_test(&layout, &info, 15.0, 15.0);
888        assert_eq!(hit_dom_id(&path), Some(42));
889    }
890
891    #[test]
892    fn scroll_at_fixed_child_ignores_ancestor_scroll() {
893        let mut layout =
894            LayoutNode::with_children(ui_layout::Style::default(), [fixed_child_layout(80.0)]);
895        layout.layout_box =
896            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
897
898        let mut info = scrollable_info();
899        set_vertical_scroll(&mut info, 50.0);
900        info.children.push(scrollable_info());
901
902        assert!(
903            scroll_at(
904                &layout,
905                &mut info,
906                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
907                15.0,
908                15.0,
909                0.0,
910                10.0
911            )
912            .is_some()
913        );
914        let NodeKind::Container {
915            scroll_offset_y: child_scroll,
916            ..
917        } = &info.children[0].kind
918        else {
919            panic!("expected fixed child container");
920        };
921        assert_eq!(*child_scroll, 10.0);
922        let NodeKind::Container {
923            scroll_offset_y: parent_scroll,
924            ..
925        } = &info.kind
926        else {
927            panic!("expected parent container");
928        };
929        assert_eq!(*parent_scroll, 50.0);
930    }
931
932    #[test]
933    fn scroll_at_scrolls_under_point_clamped_to_range() {
934        let mut layout = LayoutNode::new(ui_layout::Style::default());
935        layout.layout_box =
936            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
937        let mut info = scrollable_info();
938
939        // Cursor inside the box. Positive dy scrolls down.
940        assert!(
941            scroll_at(
942                &layout,
943                &mut info,
944                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
945                50.0,
946                50.0,
947                0.0,
948                100.0
949            )
950            .is_some()
951        );
952        let NodeKind::Container {
953            scroll_offset_y, ..
954        } = &info.kind
955        else {
956            panic!("expected container");
957        };
958        assert_eq!(*scroll_offset_y, 100.0);
959
960        // Clamp to children_box.height - content_box.height = 200.
961        assert!(
962            scroll_at(
963                &layout,
964                &mut info,
965                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
966                50.0,
967                50.0,
968                0.0,
969                300.0
970            )
971            .is_some()
972        );
973        let NodeKind::Container {
974            scroll_offset_y, ..
975        } = &info.kind
976        else {
977            panic!("expected container");
978        };
979        assert_eq!(*scroll_offset_y, 200.0);
980
981        // Cannot scroll past 0 (negative dy scrolls up).
982        assert!(
983            scroll_at(
984                &layout,
985                &mut info,
986                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
987                50.0,
988                50.0,
989                0.0,
990                -500.0
991            )
992            .is_some()
993        );
994        let NodeKind::Container {
995            scroll_offset_y, ..
996        } = &info.kind
997        else {
998            panic!("expected container");
999        };
1000        assert_eq!(*scroll_offset_y, 0.0);
1001    }
1002
1003    #[test]
1004    fn scroll_at_ignores_cursor_outside_box() {
1005        let mut layout = LayoutNode::new(ui_layout::Style::default());
1006        layout.layout_box =
1007            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
1008        let mut info = scrollable_info();
1009
1010        assert!(
1011            scroll_at(
1012                &layout,
1013                &mut info,
1014                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1015                250.0,
1016                50.0,
1017                0.0,
1018                -100.0
1019            )
1020            .is_none()
1021        );
1022    }
1023
1024    #[test]
1025    fn scroll_at_ignores_non_scrollable_containers() {
1026        let mut layout = LayoutNode::new(ui_layout::Style::default());
1027        layout.layout_box =
1028            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
1029        let mut info = InfoNode {
1030            kind: NodeKind::Container {
1031                scroll_x: false,
1032                scroll_y: false,
1033                scroll_offset_x: 0.0,
1034                scroll_offset_y: 0.0,
1035                style: ContainerStyle::default(),
1036                role: crate::engine::layouter::types::ContainerRole::Normal,
1037            },
1038            children: Vec::new(),
1039            dom_id: None,
1040        };
1041
1042        assert!(
1043            scroll_at(
1044                &layout,
1045                &mut info,
1046                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1047                50.0,
1048                50.0,
1049                0.0,
1050                -100.0
1051            )
1052            .is_none()
1053        );
1054        let NodeKind::Container {
1055            scroll_offset_y, ..
1056        } = &info.kind
1057        else {
1058            panic!("expected container");
1059        };
1060        assert_eq!(*scroll_offset_y, 0.0);
1061    }
1062
1063    #[test]
1064    fn scroll_at_prefers_innermost_scrollable_container() {
1065        // Outer container with scrollable content; inner container is
1066        // scrollable and sits inside it. A scroll over the inner container
1067        // should move the inner one, not the outer.
1068        let outer_children_box = box_model(0.0, 0.0, 400.0, 300.0, 400.0, 900.0);
1069        let inner_children_box = box_model(10.0, 10.0, 100.0, 80.0, 100.0, 240.0);
1070
1071        let mut outer_layout = LayoutNode::with_children(
1072            ui_layout::Style::default(),
1073            [LayoutNode::new(ui_layout::Style::default())],
1074        );
1075        outer_layout.layout_box = ui_layout::LayoutBox::BlockBox(outer_children_box);
1076
1077        let mut inner_layout = LayoutNode::new(ui_layout::Style::default());
1078        inner_layout.layout_box = ui_layout::LayoutBox::BlockBox(inner_children_box);
1079
1080        outer_layout.children[0] = ui_layout::LayoutChild::Node(Box::new(inner_layout));
1081
1082        let mut outer_info = scrollable_info();
1083        let inner_info = scrollable_info();
1084        outer_info.children.push(inner_info);
1085
1086        // Cursor over the inner container. Positive dy scrolls down.
1087        assert!(
1088            scroll_at(
1089                &outer_layout,
1090                &mut outer_info,
1091                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1092                50.0,
1093                50.0,
1094                0.0,
1095                30.0
1096            )
1097            .is_some()
1098        );
1099        let NodeKind::Container {
1100            scroll_offset_y, ..
1101        } = &outer_info.children[0].kind
1102        else {
1103            panic!("expected inner container");
1104        };
1105        assert_eq!(*scroll_offset_y, 30.0);
1106        let NodeKind::Container {
1107            scroll_offset_y: outer_off,
1108            ..
1109        } = &outer_info.kind
1110        else {
1111            panic!("expected outer container");
1112        };
1113        assert_eq!(*outer_off, 0.0);
1114    }
1115
1116    #[test]
1117    fn scroll_at_reports_the_scrolled_containers_dom_id() {
1118        // Outer (dom 7) with scrollable inner (dom 9): scrolling over the inner
1119        // reports the inner's dom id.
1120        let outer_children_box = box_model(0.0, 0.0, 400.0, 300.0, 400.0, 900.0);
1121        let inner_children_box = box_model(10.0, 10.0, 100.0, 80.0, 100.0, 240.0);
1122
1123        let mut outer_layout = LayoutNode::with_children(
1124            ui_layout::Style::default(),
1125            [LayoutNode::new(ui_layout::Style::default())],
1126        );
1127        outer_layout.layout_box = ui_layout::LayoutBox::BlockBox(outer_children_box);
1128        let mut inner_layout = LayoutNode::new(ui_layout::Style::default());
1129        inner_layout.layout_box = ui_layout::LayoutBox::BlockBox(inner_children_box);
1130        outer_layout.children[0] = ui_layout::LayoutChild::Node(Box::new(inner_layout));
1131
1132        let mut outer_info = container_info(true, Some(7));
1133        let inner_info = container_info(true, Some(9));
1134        outer_info.children.push(inner_info);
1135
1136        let scrolled = scroll_at(
1137            &outer_layout,
1138            &mut outer_info,
1139            (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1140            50.0,
1141            50.0,
1142            0.0,
1143            30.0,
1144        );
1145        assert_eq!(scrolled, Some(9));
1146    }
1147
1148    #[test]
1149    fn scroll_at_reports_marker_when_scrolled_container_has_no_dom_id() {
1150        let mut layout = LayoutNode::new(ui_layout::Style::default());
1151        layout.layout_box =
1152            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
1153        let mut info = container_info(true, None);
1154        let scrolled = scroll_at(
1155            &layout,
1156            &mut info,
1157            (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1158            50.0,
1159            50.0,
1160            0.0,
1161            10.0,
1162        );
1163        assert_eq!(scrolled, Some(NO_SCROLL_DOM_ID));
1164    }
1165
1166    /// Records pointer events for asserting `dispatch_pointer` coordinates.
1167    #[derive(Debug)]
1168    struct RecordingNode {
1169        pointer_events: Mutex<Vec<PointerEvent>>,
1170        popup_events: Mutex<Vec<PointerEvent>>,
1171        popup_rect: Rect,
1172        popup_open: AtomicBool,
1173        dismissed: AtomicBool,
1174    }
1175
1176    impl RecordingNode {
1177        fn new(popup_rect: Rect) -> Self {
1178            RecordingNode {
1179                pointer_events: Mutex::new(Vec::new()),
1180                popup_events: Mutex::new(Vec::new()),
1181                popup_rect,
1182                popup_open: AtomicBool::new(true),
1183                dismissed: AtomicBool::new(false),
1184            }
1185        }
1186
1187        fn events(&self) -> Vec<PointerEvent> {
1188            self.pointer_events.lock().unwrap().clone()
1189        }
1190
1191        fn popup_events(&self) -> Vec<PointerEvent> {
1192            self.popup_events.lock().unwrap().clone()
1193        }
1194    }
1195
1196    impl CustomNode for RecordingNode {
1197        fn draw_sized(
1198            &self,
1199            _cmd_buf: &mut Vec<DrawCommand>,
1200            _text_style: &TextStyle,
1201            _text_flow_style: &TextFlowStyle,
1202            _style: &Style,
1203            _size: ContentSize,
1204        ) {
1205        }
1206
1207        fn intrinsic_size(&self) -> ContentSize {
1208            ContentSize {
1209                width: 120.0,
1210                height: 28.0,
1211            }
1212        }
1213
1214        fn on_pointer_event(&self, event: PointerEvent) -> bool {
1215            self.pointer_events.lock().unwrap().push(event);
1216            true
1217        }
1218
1219        fn popup(
1220            &self,
1221            _text_style: &TextStyle,
1222            _text_flow_style: &TextFlowStyle,
1223        ) -> Option<Popup> {
1224            self.popup_open.load(Ordering::Relaxed).then(|| Popup {
1225                rect: self.popup_rect,
1226                commands: Vec::new(),
1227            })
1228        }
1229
1230        fn on_popup_pointer_event(&self, event: PointerEvent) -> bool {
1231            self.popup_events.lock().unwrap().push(event);
1232            true
1233        }
1234
1235        fn dismiss_popup(&self) {
1236            self.dismissed.store(true, Ordering::Relaxed);
1237        }
1238    }
1239
1240    /// Tree: root (0,0,200,200) → container (10,20,160,100, scroll_y) →
1241    /// custom node (5,0,120,28) with an open popup at (0,28,120,84).
1242    fn make_tree(a_scroll_y: f32) -> (LayoutNode, InfoNode, Arc<RecordingNode>) {
1243        let node: Arc<RecordingNode> = Arc::new(RecordingNode::new(Rect {
1244            x: 0.0,
1245            y: 28.0,
1246            width: 120.0,
1247            height: 84.0,
1248        }));
1249        let custom_info = input_info(Arc::clone(&node) as Arc<dyn CustomNode>);
1250
1251        let mut a_layout = LayoutNode::new(ui_layout::Style::default());
1252        a_layout.layout_box =
1253            ui_layout::LayoutBox::BlockBox(box_model(10.0, 20.0, 160.0, 100.0, 160.0, 300.0));
1254        let mut custom_layout = LayoutNode::new(ui_layout::Style::default());
1255        custom_layout.layout_box =
1256            ui_layout::LayoutBox::BlockBox(box_model(5.0, 0.0, 120.0, 28.0, 120.0, 28.0));
1257        a_layout.children = vec![LayoutChild::Node(Box::new(custom_layout))];
1258
1259        let mut root_layout = LayoutNode::with_children(ui_layout::Style::default(), [a_layout]);
1260        root_layout.layout_box =
1261            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 200.0, 200.0, 200.0));
1262
1263        let a_info = InfoNode {
1264            kind: NodeKind::Container {
1265                scroll_x: false,
1266                scroll_y: true,
1267                scroll_offset_x: 0.0,
1268                scroll_offset_y: a_scroll_y,
1269                style: ContainerStyle::default(),
1270                role: ContainerRole::Normal,
1271            },
1272            children: vec![custom_info],
1273            dom_id: None,
1274        };
1275        let root_info = InfoNode {
1276            kind: NodeKind::Container {
1277                scroll_x: false,
1278                scroll_y: false,
1279                scroll_offset_x: 0.0,
1280                scroll_offset_y: 0.0,
1281                style: ContainerStyle::default(),
1282                role: ContainerRole::Normal,
1283            },
1284            children: vec![a_info],
1285            dom_id: None,
1286        };
1287
1288        (root_layout, root_info, node)
1289    }
1290
1291    /// Builds the child→parent hit path matching `make_tree`.
1292    fn build_path<'a>(root_layout: &'a LayoutNode, root_info: &'a InfoNode) -> HitPath<'a> {
1293        let a_layout = match &root_layout.children[0] {
1294            LayoutChild::Node(node) => node,
1295            _ => unreachable!("expected container child"),
1296        };
1297        let custom_layout = match &a_layout.children[0] {
1298            LayoutChild::Node(node) => node,
1299            _ => unreachable!("expected custom child"),
1300        };
1301        let a_info = &root_info.children[0];
1302        let custom_info = &a_info.children[0];
1303        vec![
1304            HitItem {
1305                layout: custom_layout,
1306                info: custom_info,
1307            },
1308            HitItem {
1309                layout: a_layout,
1310                info: a_info,
1311            },
1312            HitItem {
1313                layout: root_layout,
1314                info: root_info,
1315            },
1316        ]
1317    }
1318
1319    #[test]
1320    fn dispatch_pointer_uses_node_local_coords() {
1321        let (root_layout, root_info, node) = make_tree(0.0);
1322        node.popup_open.store(false, Ordering::Relaxed);
1323        let path = build_path(&root_layout, &root_info);
1324
1325        // Content origin (15,20); no scroll: (20,25) → local (5,5).
1326        dispatch_pointer(&path, PointerEvent::Down { x: 20.0, y: 25.0 });
1327        assert_eq!(node.events(), vec![PointerEvent::Down { x: 5.0, y: 5.0 }]);
1328    }
1329
1330    #[test]
1331    fn dispatch_pointer_folds_ancestor_scroll_into_local_coords() {
1332        let (root_layout, root_info, node) = make_tree(50.0);
1333        node.popup_open.store(false, Ordering::Relaxed);
1334        let path = build_path(&root_layout, &root_info);
1335
1336        // Same click with a 50px ancestor scroll: local y += 50.
1337        dispatch_pointer(&path, PointerEvent::Move { x: 20.0, y: 25.0 });
1338        assert_eq!(node.events(), vec![PointerEvent::Move { x: 5.0, y: 55.0 }]);
1339    }
1340
1341    #[test]
1342    fn popup_events_use_popup_local_coords() {
1343        let (root_layout, root_info, node) = make_tree(0.0);
1344        let path = build_path(&root_layout, &root_info);
1345
1346        // Global (20,60) → local (5,40) → inside popup (y in 28..112) →
1347        // popup-local (5, 40-28=12).
1348        dispatch_pointer(&path, PointerEvent::Down { x: 20.0, y: 60.0 });
1349        assert_eq!(
1350            node.popup_events(),
1351            vec![PointerEvent::Down { x: 5.0, y: 12.0 }]
1352        );
1353        assert!(node.events().is_empty());
1354        assert!(!node.dismissed.load(Ordering::Relaxed));
1355    }
1356
1357    #[test]
1358    fn down_outside_popup_routes_to_node() {
1359        let (root_layout, root_info, node) = make_tree(0.0);
1360        let path = build_path(&root_layout, &root_info);
1361
1362        // Global (20,30) → local (5,10), above the popup: the node receives
1363        // the press (dismissal is handled globally by `dismiss_open_popups`).
1364        dispatch_pointer(&path, PointerEvent::Down { x: 20.0, y: 30.0 });
1365        assert!(!node.dismissed.load(Ordering::Relaxed));
1366        assert_eq!(node.events(), vec![PointerEvent::Down { x: 5.0, y: 10.0 }]);
1367        assert!(node.popup_events().is_empty());
1368    }
1369
1370    #[test]
1371    fn move_outside_popup_routes_to_node() {
1372        let (root_layout, root_info, node) = make_tree(0.0);
1373        let path = build_path(&root_layout, &root_info);
1374
1375        // Global (20,30) → local (5,10), above the popup: the node receives
1376        // the move and the popup stays open.
1377        dispatch_pointer(&path, PointerEvent::Move { x: 20.0, y: 30.0 });
1378        assert!(!node.dismissed.load(Ordering::Relaxed));
1379        assert_eq!(node.events(), vec![PointerEvent::Move { x: 5.0, y: 10.0 }]);
1380        assert!(node.popup_events().is_empty());
1381    }
1382
1383    fn popup_hit_assert(
1384        root_layout: &LayoutNode,
1385        root_info: &InfoNode,
1386        node: &Arc<RecordingNode>,
1387        x: f32,
1388        y: f32,
1389    ) {
1390        let path = hit_test(root_layout, root_info, x, y);
1391        let hit = hit_custom_node(&path).unwrap();
1392        let expected: Arc<dyn CustomNode> = node.clone();
1393        assert!(Arc::ptr_eq(hit, &expected));
1394    }
1395
1396    #[test]
1397    fn hit_test_finds_open_popup() {
1398        let (root_layout, root_info, node) = make_tree(0.0);
1399        // Custom page origin (15,20); popup page rect (15,48)-(135,132).
1400        // Global (20,60) → local (5,40), inside the popup (y in 28..112).
1401        popup_hit_assert(&root_layout, &root_info, &node, 20.0, 60.0);
1402    }
1403
1404    #[test]
1405    fn hit_test_ignores_closed_popup() {
1406        let (root_layout, root_info, node) = make_tree(0.0);
1407        node.popup_open.store(false, Ordering::Relaxed);
1408        // Local (5,40) is below the custom box (y in 0..28); with the popup
1409        // closed the click lands on the container instead.
1410        let path = hit_test(&root_layout, &root_info, 20.0, 60.0);
1411        assert_eq!(path.len(), 2);
1412        assert!(hit_custom_node(&path).is_none());
1413    }
1414
1415    #[test]
1416    fn hit_test_finds_popup_escaping_ancestor_box() {
1417        let (root_layout, root_info, node) = make_tree(0.0);
1418        // The container box ends at y=120 but the popup reaches y=132. A click
1419        // past the container is still a popup hit because popups render above
1420        // ancestor boxes and clips.
1421        popup_hit_assert(&root_layout, &root_info, &node, 20.0, 125.0);
1422    }
1423
1424    #[test]
1425    fn hit_test_folds_scroll_into_popup_hit() {
1426        let (root_layout, root_info, node) = make_tree(50.0);
1427        // With the 50px ancestor scroll, a click at page y=25 maps to local
1428        // (5,55), inside the popup (y in 28..112).
1429        popup_hit_assert(&root_layout, &root_info, &node, 20.0, 25.0);
1430    }
1431
1432    #[test]
1433    fn dismiss_open_popups_closes_popup_on_outside_press() {
1434        let (root_layout, root_info, node) = make_tree(0.0);
1435        // (10,25) lands on the container, outside the custom box and popup.
1436        let path = hit_test(&root_layout, &root_info, 10.0, 25.0);
1437        assert!(dismiss_open_popups(&root_info, &path));
1438        assert!(node.dismissed.load(Ordering::Relaxed));
1439    }
1440
1441    #[test]
1442    fn dismiss_open_popups_keeps_popup_under_press() {
1443        let (root_layout, root_info, node) = make_tree(0.0);
1444        // Press on the open popup itself.
1445        let path = hit_test(&root_layout, &root_info, 20.0, 60.0);
1446        assert!(!dismiss_open_popups(&root_info, &path));
1447        assert!(!node.dismissed.load(Ordering::Relaxed));
1448    }
1449
1450    #[test]
1451    fn dismiss_open_popups_keeps_popup_when_press_on_owner_box() {
1452        let (root_layout, root_info, node) = make_tree(0.0);
1453        // Press on the owning box (not the popup): the owner closes it itself,
1454        // so no separate dismissal happens.
1455        let path = hit_test(&root_layout, &root_info, 20.0, 30.0);
1456        assert!(!dismiss_open_popups(&root_info, &path));
1457        assert!(!node.dismissed.load(Ordering::Relaxed));
1458    }
1459
1460    #[test]
1461    fn dismiss_open_popups_ignores_closed_popup() {
1462        let (root_layout, root_info, node) = make_tree(0.0);
1463        node.popup_open.store(false, Ordering::Relaxed);
1464        let path = hit_test(&root_layout, &root_info, 10.0, 25.0);
1465        assert!(!dismiss_open_popups(&root_info, &path));
1466        assert!(!node.dismissed.load(Ordering::Relaxed));
1467    }
1468
1469    /// Tree: block `b` (0,0,300,100) → inline `i` (line box 10,10,200,20) →
1470    /// block child `c` (30,10,50,20). Inline content shares the block's
1471    /// content space, so `c` sits at absolute (30,10) inside `b`.
1472    fn make_inline_tree() -> (LayoutNode, InfoNode) {
1473        let mut c = LayoutNode::new(ui_layout::Style::default());
1474        c.layout_box =
1475            ui_layout::LayoutBox::BlockBox(box_model(30.0, 10.0, 50.0, 20.0, 50.0, 20.0));
1476        let mut b =
1477            LayoutNode::with_children(ui_layout::Style::default(), [inline_node(vec![c.into()])]);
1478        b.layout_box =
1479            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 300.0, 100.0, 300.0, 100.0));
1480
1481        let c_info = container_info(false, Some(7));
1482        let mut i_info = container_info(false, None);
1483        i_info.children.push(c_info);
1484        let mut b_info = container_info(false, None);
1485        b_info.children.push(i_info);
1486
1487        (b, b_info)
1488    }
1489
1490    #[test]
1491    fn hit_test_inside_inline_keeps_parent_coords() {
1492        let (b, b_info) = make_inline_tree();
1493        // Inline boxes push no transform, so the child's coordinates are
1494        // resolved in the block's content space: (40,15) falls inside `c` at
1495        // (30,10,50,20) even though `i`'s content origin is (10,10).
1496        let path = hit_test(&b, &b_info, 40.0, 15.0);
1497        assert_eq!(hit_dom_id(&path), Some(7));
1498        assert_eq!(path.len(), 3);
1499    }
1500
1501    #[test]
1502    fn hit_test_inside_inline_but_outside_child_hits_inline() {
1503        let (b, b_info) = make_inline_tree();
1504        // (12,12) is inside `i`'s line box but left of `c`; the inline
1505        // container itself is hit instead.
1506        let path = hit_test(&b, &b_info, 12.0, 12.0);
1507        assert_eq!(path.len(), 2);
1508        assert_eq!(hit_dom_id(&path), None);
1509    }
1510
1511    #[test]
1512    fn hit_test_outside_inline_line_boxes_falls_back_to_parent() {
1513        let (b, b_info) = make_inline_tree();
1514        // (40,50) is inside `b` but below `i`'s line box (10..30).
1515        let path = hit_test(&b, &b_info, 40.0, 50.0);
1516        assert_eq!(path.len(), 1);
1517        assert_eq!(hit_dom_id(&path), None);
1518    }
1519
1520    #[test]
1521    fn dispatch_pointer_to_inline_custom_keeps_parent_content_coords() {
1522        let node: Arc<RecordingNode> = Arc::new(RecordingNode::new(Rect {
1523            x: 0.0,
1524            y: 28.0,
1525            width: 120.0,
1526            height: 84.0,
1527        }));
1528        let custom_info = input_info(Arc::clone(&node) as Arc<dyn CustomNode>);
1529
1530        let mut inline_layout = LayoutNode::new(ui_layout::Style::default());
1531        inline_layout.layout_box = ui_layout::LayoutBox::InlineBox(ui_layout::InlineBox {
1532            box_model: box_model(10.0, 20.0, 160.0, 100.0, 160.0, 100.0),
1533            line_spans: vec![ui_layout::LineSpan {
1534                x_range: 0.0..160.0,
1535                line_pos: (10.0, 20.0),
1536                line_index: 0,
1537            }],
1538        });
1539        let mut block_layout = LayoutNode::new(ui_layout::Style::default());
1540        block_layout.layout_box =
1541            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 200.0, 200.0, 200.0));
1542
1543        let inline_info = container_info(false, None);
1544        let root_info = container_info(false, None);
1545
1546        node.popup_open.store(false, Ordering::Relaxed);
1547        let path = vec![
1548            HitItem {
1549                layout: &inline_layout,
1550                info: &custom_info,
1551            },
1552            HitItem {
1553                layout: &inline_layout,
1554                info: &inline_info,
1555            },
1556            HitItem {
1557                layout: &block_layout,
1558                info: &root_info,
1559            },
1560        ];
1561
1562        // Inline boxes contribute no content-origin offset, so the event is
1563        // delivered in the block's content space, not shifted by `i`'s (10,20)
1564        // content origin.
1565        dispatch_pointer(&path, PointerEvent::Down { x: 30.0, y: 35.0 });
1566        assert_eq!(node.events(), vec![PointerEvent::Down { x: 30.0, y: 35.0 }]);
1567    }
1568
1569    #[test]
1570    fn scroll_at_inside_inline_scrolls_child_in_parent_coords() {
1571        let mut c = LayoutNode::new(ui_layout::Style::default());
1572        c.layout_box =
1573            ui_layout::LayoutBox::BlockBox(box_model(30.0, 10.0, 50.0, 80.0, 50.0, 240.0));
1574        let mut b =
1575            LayoutNode::with_children(ui_layout::Style::default(), [inline_node(vec![c.into()])]);
1576        b.layout_box =
1577            ui_layout::LayoutBox::BlockBox(box_model(0.0, 0.0, 200.0, 100.0, 200.0, 300.0));
1578
1579        let c_info = container_info(true, None);
1580        let mut i_info = container_info(false, None);
1581        i_info.children.push(c_info);
1582        let mut b_info = scrollable_info();
1583        b_info.children.push(i_info);
1584
1585        assert!(
1586            scroll_at(
1587                &b,
1588                &mut b_info,
1589                (VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
1590                40.0,
1591                15.0,
1592                0.0,
1593                10.0
1594            )
1595            .is_some()
1596        );
1597        // The child under the cursor scrolls; the block parent does not.
1598        assert_eq!(scroll_offset_y_of(&b_info.children[0].children[0]), 10.0);
1599        assert_eq!(scroll_offset_y_of(&b_info), 0.0);
1600    }
1601}