diff --git a/CLAUDE.md b/CLAUDE.md index 081b036..edc11e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -216,6 +216,7 @@ Line-based, one command per line. `#` starts a comment. | `dump_console ` | Write captured console.log/warn/error output. | | `assert_dom_contains ""` | Fail if the DOM dump lacks ``. | | `assert_console_contains ""` | Fail if the console capture lacks ``. | +| `assert_link_at ` | Resolve the link a native click on the first element matching `` would follow — finds the element's rendered center, then runs the production hit-test + ancestor-`` walk (the same path a real mouse click takes) — and fail unless the resolved `href` contains ``. Because `#` is the comment delimiter, write an ID match as `[id=foo]`. | | `assert_screenshot_matches [--tolerance N] [--max-diff-pct P]` | Fail if `` differs from the golden `` by more than `--max-diff-pct` percent of pixels (default `0.1`), where a pixel counts as differing when any RGBA channel exceeds `--tolerance` (default `4`). On failure writes a magenta-highlighted diff image at `.diff.png`. `` is resolved relative to `--out-dir`; `` is resolved relative to the scenario file. | The JS VM stays alive across commands within a single page session, so diff --git a/crates/browser/src/hit_test.rs b/crates/browser/src/hit_test.rs new file mode 100644 index 0000000..480e779 --- /dev/null +++ b/crates/browser/src/hit_test.rs @@ -0,0 +1,218 @@ +//! Pointer hit-testing shared by the production browser shell and the headless +//! e2e harness. +//! +//! These helpers operate purely on a laid-out [`LayoutBox`] tree plus the +//! [`Document`], so they can run identically in `main.rs` (real mouse events) +//! and in the offline test harness (synthetic clicks). Keeping a single +//! implementation means an e2e assertion about link clickability exercises the +//! exact code path a real click takes. +//! +//! Layout stores **absolute document coordinates** in every box's `rect` (see +//! `we_layout`: a child's content origin is `parent.rect.{x,y}`, and the +//! renderer paints each box at `rect + scroll` without accumulating ancestor +//! offsets). Hit-testing therefore reads `rect` directly and must *not* add +//! ancestor positions, or nested elements drift further off with depth. + +use we_dom::{Document, NodeId}; +use we_layout::{BoxType, LayoutBox}; +use we_style::computed::Overflow; + +/// The absolute border-box rectangle `(x, y, w, h)` of a layout box. +fn border_box(layout_box: &LayoutBox) -> (f32, f32, f32, f32) { + let bx = layout_box.rect.x - layout_box.padding.left - layout_box.border.left; + let by = layout_box.rect.y - layout_box.padding.top - layout_box.border.top; + let bw = layout_box.rect.width + + layout_box.padding.left + + layout_box.padding.right + + layout_box.border.left + + layout_box.border.right; + let bh = layout_box.rect.height + + layout_box.padding.top + + layout_box.padding.bottom + + layout_box.border.top + + layout_box.border.bottom; + (bx, by, bw, bh) +} + +/// Hit-test the layout tree for any element at the given absolute coordinates. +/// +/// Returns the deepest (topmost in paint order) `NodeId` whose layout box +/// contains the point. Children are visited front-to-back so the element +/// painted on top wins. +/// +/// Children are tested even when the point falls outside *this* box: with the +/// default `overflow: visible`, a child laid out beyond its parent's content +/// box (e.g. a flex/grid item that overflows, or an absolutely-positioned +/// descendant) is still painted and still clickable. A box that clips its +/// overflow (`overflow: hidden | scroll | auto`) confines its descendants, so +/// when the point is outside such a box neither it nor its subtree can be hit. +pub fn hit_test_any_element(layout_box: &LayoutBox, x: f32, y: f32) -> Option { + let (bx, by, bw, bh) = border_box(layout_box); + let inside = x >= bx && x < bx + bw && y >= by && y < by + bh; + + // A clipping box confines its descendants to its own box, so a point + // outside it can hit neither the box nor anything within it. + if layout_box.overflow != Overflow::Visible && !inside { + return None; + } + + // Check children first (deepest / topmost in paint order wins), descending + // regardless of `inside` so overflowing content stays hittable. + for child in layout_box.children.iter().rev() { + if let Some(hit) = hit_test_any_element(child, x, y) { + return Some(hit); + } + } + + if inside { + match layout_box.box_type { + BoxType::Block(node) | BoxType::Inline(node) => Some(node), + BoxType::TextRun { node, .. } => Some(node), + BoxType::Anonymous => None, + } + } else { + None + } +} + +/// Walk up the DOM from `node` looking for an ancestor `` element with an +/// `href` attribute. Returns the anchor node and its raw href value. +pub fn find_ancestor_anchor(doc: &Document, node: NodeId) -> Option<(NodeId, &str)> { + let mut current = Some(node); + while let Some(id) = current { + if doc.tag_name(id) == Some("a") { + if let Some(href) = doc.get_attribute(id, "href") { + return Some((id, href)); + } + } + current = doc.parent(id); + } + None +} + +/// Find the absolute center point of the layout box for `target`. Returns the +/// border-box center in document coordinates, or `None` if the node has no +/// layout box. +pub fn element_center(layout_box: &LayoutBox, target: NodeId) -> Option<(f32, f32)> { + let node = match layout_box.box_type { + BoxType::Block(n) | BoxType::Inline(n) => Some(n), + BoxType::TextRun { node, .. } => Some(node), + BoxType::Anonymous => None, + }; + + if node == Some(target) { + let (bx, by, bw, bh) = border_box(layout_box); + return Some((bx + bw / 2.0, by + bh / 2.0)); + } + + for child in &layout_box.children { + if let Some(c) = element_center(child, target) { + return Some(c); + } + } + None +} + +/// Resolve the link a native click at absolute `(x, y)` would follow: hit-test +/// the tree for the topmost element, then walk up to its nearest ancestor +/// ``. Returns the resolved href, or `None` if the click does not land +/// on a link. +pub fn resolve_anchor_at_point(root: &LayoutBox, doc: &Document, x: f32, y: f32) -> Option { + let hit = hit_test_any_element(root, x, y)?; + find_ancestor_anchor(doc, hit).map(|(_, href)| href.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use we_layout::Rect; + use we_style::computed::ComputedStyle; + + /// Build a block box for `node` at an absolute `rect`, with default styling. + fn block(node: usize, x: f32, y: f32, w: f32, h: f32) -> LayoutBox { + let mut b = LayoutBox::from_style( + BoxType::Block(NodeId::from_index(node)), + &ComputedStyle::default(), + ); + b.rect = Rect { + x, + y, + width: w, + height: h, + }; + b + } + + #[test] + fn hit_test_reads_absolute_coordinates() { + // rect already holds absolute document coordinates; nesting must not + // inflate the hit position. The deep child sits at absolute (110, 510). + let mut root = block(0, 0.0, 0.0, 800.0, 2000.0); + let mut mid = block(1, 100.0, 500.0, 200.0, 60.0); + let child = block(2, 110.0, 510.0, 50.0, 20.0); + mid.children.push(child); + root.children.push(mid); + + // A point inside the deep child resolves to it (not an inflated miss). + assert_eq!( + hit_test_any_element(&root, 120.0, 515.0), + Some(NodeId::from_index(2)) + ); + // Its center is read straight from the absolute rect. + assert_eq!( + element_center(&root, NodeId::from_index(2)), + Some((135.0, 520.0)) + ); + } + + #[test] + fn hit_test_descends_into_overflowing_child() { + // The child is laid out below its (overflow: visible) host's 20px box. + let mut root = block(0, 0.0, 0.0, 800.0, 600.0); + let mut host = block(1, 0.0, 0.0, 100.0, 20.0); // overflow defaults to Visible + let child = block(2, 0.0, 80.0, 100.0, 20.0); + host.children.push(child); + root.children.push(host); + + // The point is outside the host but inside the overflowing child. + assert_eq!( + hit_test_any_element(&root, 50.0, 90.0), + Some(NodeId::from_index(2)) + ); + } + + #[test] + fn hit_test_clips_overflow_hidden_subtree() { + let mut root = block(0, 0.0, 0.0, 800.0, 600.0); + let mut host = block(1, 0.0, 0.0, 100.0, 20.0); + host.overflow = Overflow::Hidden; + let child = block(2, 0.0, 80.0, 100.0, 20.0); + host.children.push(child); + root.children.push(host); + + // The child is clipped away: a point outside the host does not hit the + // clipped child; it falls through to the root behind it. + assert_eq!( + hit_test_any_element(&root, 50.0, 90.0), + Some(NodeId::from_index(0)) + ); + // A point inside the clipping host still hits the host itself. + assert_eq!( + hit_test_any_element(&root, 50.0, 10.0), + Some(NodeId::from_index(1)) + ); + } + + #[test] + fn resolve_anchor_walks_to_ancestor_link() { + let doc = we_html::parse_html(r#"label"#); + let span = (0..doc.len()) + .map(NodeId::from_index) + .find(|&id| doc.tag_name(id) == Some("span")) + .expect("span exists"); + assert_eq!( + find_ancestor_anchor(&doc, span).map(|(_, h)| h), + Some("/go") + ); + } +} diff --git a/crates/browser/src/lib.rs b/crates/browser/src/lib.rs index 2058212..3852b50 100644 --- a/crates/browser/src/lib.rs +++ b/crates/browser/src/lib.rs @@ -8,6 +8,7 @@ pub mod csp; pub mod css_loader; pub mod font_loader; pub mod form_submission; +pub mod hit_test; pub mod iframe_loader; pub mod img_loader; pub mod import_map; diff --git a/crates/browser/src/main.rs b/crates/browser/src/main.rs index 5fdef0f..ed288e1 100644 --- a/crates/browser/src/main.rs +++ b/crates/browser/src/main.rs @@ -8,6 +8,7 @@ use we_browser::form_submission::{ construct_entry_list, encode_multipart, encode_text_plain, encode_urlencoded, resolve_action, submission_params, FormEnctype, FormMethod, }; +use we_browser::hit_test::{find_ancestor_anchor, hit_test_any_element}; use we_browser::img_loader::{collect_images, ImageStore}; use we_browser::loader::{LoadError, Resource, ResourceLoader}; use we_browser::navigation_history::NavigationHistory; @@ -600,7 +601,7 @@ fn hit_test_chrome( let Some(cr) = build_chrome_layout(chrome, history, font, viewport_width) else { return ChromeHit::None; }; - let Some(node) = hit_test_any_element(&cr.tree.root, x, y, 0.0, 0.0) else { + let Some(node) = hit_test_any_element(&cr.tree.root, x, y) else { return ChromeHit::None; }; // Walk up from the hit node (e.g. the SVG inside a button) to the nearest @@ -612,7 +613,7 @@ fn hit_test_chrome( Some(we_browser::chrome_ui::ID_FORWARD) => return ChromeHit::Forward, Some(we_browser::chrome_ui::ID_RELOAD) => return ChromeHit::Reload, Some(we_browser::chrome_ui::ID_URLBAR) => { - let content_x = chrome_box_content_x(&cr.tree.root, n, 0.0).unwrap_or(x); + let content_x = chrome_box_content_x(&cr.tree.root, n).unwrap_or(x); return ChromeHit::AddressBar { local_x: x - content_x, }; @@ -624,20 +625,21 @@ fn hit_test_chrome( ChromeHit::None } -/// Absolute x of the content-box left edge of the layout box for `target`, -/// walking the chrome layout tree while accumulating parent offsets. -fn chrome_box_content_x(b: &we_layout::LayoutBox, target: NodeId, parent_x: f32) -> Option { +/// Absolute x of the content-box left edge of the layout box for `target`. +/// +/// Layout stores absolute document coordinates in each box's `rect`, so the +/// box's `rect.x` is read directly rather than accumulating ancestor offsets. +fn chrome_box_content_x(b: &we_layout::LayoutBox, target: NodeId) -> Option { let node = match b.box_type { we_layout::BoxType::Block(n) | we_layout::BoxType::Inline(n) => Some(n), we_layout::BoxType::TextRun { node, .. } => Some(node), we_layout::BoxType::Anonymous => None, }; - let abs_x = parent_x + b.rect.x; if node == Some(target) { - return Some(abs_x); + return Some(b.rect.x); } for child in &b.children { - if let Some(v) = chrome_box_content_x(child, target, abs_x) { + if let Some(v) = chrome_box_content_x(child, target) { return Some(v); } } @@ -2270,7 +2272,7 @@ fn handle_mouse_down(x: f64, y: f64, click_count: u32, mods: appkit::KeyModifier // Hit-test the layout tree for any form control. if let Some((node, local_x, _content_width, font_size)) = - hit_test_form_control(&tree.root, view_x, view_y, 0.0, 0.0) + hit_test_form_control(&tree.root, view_x, view_y) { if checkbox_or_radio_type(&state.page.doc, node).is_some() { // Checkbox or radio button click. @@ -2336,7 +2338,7 @@ fn handle_mouse_down(x: f64, y: f64, click_count: u32, mods: appkit::KeyModifier state.page.doc.set_active_element(Some(node), false); } rerender(state); - } else if let Some(hit_node) = hit_test_any_element(&tree.root, view_x, view_y, 0.0, 0.0) { + } else if let Some(hit_node) = hit_test_any_element(&tree.root, view_x, view_y) { // No form control hit. Check for link click first, then label delegation. if let Some((_anchor, href)) = find_ancestor_anchor(&state.page.doc, hit_node) { let href = href.to_string(); @@ -2488,8 +2490,7 @@ fn handle_mouse_dragged(x: f64, y: f64) { &img_sizes, ); - if let Some((_, local_x, _, font_size)) = - hit_test_form_control(&tree.root, view_x, view_y, 0.0, 0.0) + if let Some((_, local_x, _, font_size)) = hit_test_form_control(&tree.root, view_x, view_y) { let char_width = font_size * 0.6; let char_idx = if char_width > 0.0 { @@ -2514,19 +2515,23 @@ fn char_to_byte_offset(s: &str, char_idx: usize) -> usize { .unwrap_or(s.len()) } -/// Hit-test the layout tree for a form control at the given coordinates. +/// Hit-test the layout tree for a form control at the given absolute coordinates. /// /// Returns `(NodeId, local_x_in_content, content_width, font_size)` if a /// form control was hit (any type: text input, checkbox, radio, button, etc.). +/// +/// Layout stores absolute document coordinates in each box's `rect`, so the +/// box position is read directly without accumulating ancestor offsets. +/// Children are visited even when the point falls outside this box (so a +/// control overflowing an `overflow: visible` ancestor stays hittable); a box +/// that clips its overflow confines its descendants. fn hit_test_form_control( layout_box: &we_layout::LayoutBox, x: f32, y: f32, - parent_x: f32, - parent_y: f32, ) -> Option<(NodeId, f32, f32, f32)> { - let bx = parent_x + layout_box.rect.x - layout_box.padding.left - layout_box.border.left; - let by = parent_y + layout_box.rect.y - layout_box.padding.top - layout_box.border.top; + let bx = layout_box.rect.x - layout_box.padding.left - layout_box.border.left; + let by = layout_box.rect.y - layout_box.padding.top - layout_box.border.top; let bw = layout_box.rect.width + layout_box.padding.left + layout_box.padding.right @@ -2538,80 +2543,29 @@ fn hit_test_form_control( + layout_box.border.top + layout_box.border.bottom; - // Check if point is within this box. - if x >= bx && x < bx + bw && y >= by && y < by + bh { - // Check children first (front-to-back). - for child in layout_box.children.iter().rev() { - if let Some(hit) = hit_test_form_control( - child, - x, - y, - parent_x + layout_box.rect.x, - parent_y + layout_box.rect.y, - ) { - return Some(hit); - } - } + let inside = x >= bx && x < bx + bw && y >= by && y < by + bh; + if layout_box.overflow != we_style::computed::Overflow::Visible && !inside { + return None; + } - // If this is any form control, return it. - if layout_box.form_control.is_some() { - if let we_layout::BoxType::Block(node) | we_layout::BoxType::Inline(node) = - layout_box.box_type - { - let content_x = parent_x + layout_box.rect.x; - let local_x = (x - content_x).max(0.0); - return Some((node, local_x, layout_box.rect.width, layout_box.font_size)); - } + // Check children first (front-to-back); descend regardless of `inside` so + // overflowing controls remain hittable. + for child in layout_box.children.iter().rev() { + if let Some(hit) = hit_test_form_control(child, x, y) { + return Some(hit); } } - None -} - -/// Hit-test the layout tree for any element at the given coordinates. -/// -/// Returns the deepest `NodeId` whose layout box contains the point. -/// Used for detecting clicks on `