Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ depending on Chromium/WebKit/Gecko internals.
average-character-width heuristic (`AVG_CHAR_WIDTH_PX`/`LINE_HEIGHT_PX`
in `layout/engine.rs`), not real font metrics. This is a known,
documented approximation until HarfBuzz/font integration in a later batch.
- [ ] Inline elements (`<span>`, `<a>`, ...) don't get their own boxes yet —
their text is flattened into the surrounding run
- [x] Inline `<a>` link elements: bounding box geometry computation, 2D hit-testing, and interactive hyperlink navigation
- [ ] Remaining inline elements (`<span>`, `<b>`, ...) flattened into surrounding runs

### Batch 4 — Paint + Compositor (1-1.5 months) done
- [x] Layout tree → paint command list (rects, text runs, images)
Expand All @@ -76,6 +76,7 @@ depending on Chromium/WebKit/Gecko internals.
### Batch 8 — Browser Shell / UI done
- [x] Tab management, address bar, navigation history
- [x] Live URL fetching integration
- [x] Interactive hyperlink navigation: click `<a>` elements to navigate directly within active tab
- [ ] Per-site permission prompts (camera/mic/location)
- [ ] Privacy dashboard (what's blocked, per-origin storage view)

Expand Down
28 changes: 27 additions & 1 deletion src/layout/box_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ impl Rect {
height: self.height + edge.top + edge.bottom,
}
}

pub fn contains(&self, px: f32, py: f32) -> bool {
px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
}
}

#[derive(Debug, Clone, Copy, Default, PartialEq)]
Expand Down Expand Up @@ -47,4 +51,26 @@ impl Dimensions {
pub fn margin_box(&self) -> Rect {
self.border_box().expanded_by(self.margin)
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_rect_contains_point() {
let rect = Rect {
x: 10.0,
y: 20.0,
width: 100.0,
height: 50.0,
};
assert!(rect.contains(10.0, 20.0));
assert!(rect.contains(110.0, 70.0));
assert!(rect.contains(50.0, 40.0));
assert!(!rect.contains(9.9, 20.0));
assert!(!rect.contains(10.0, 19.9));
assert!(!rect.contains(110.1, 50.0));
assert!(!rect.contains(50.0, 70.1));
}
}
71 changes: 71 additions & 0 deletions src/layout/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,75 @@ mod tests {
assert_eq!(abs_box.dimensions.content.x, 50.0);
assert_eq!(abs_box.dimensions.content.y, 30.0);
}

#[test]
fn test_layout_box_hit_test() {
let mut root = LayoutBox::new(BoxType::Block(0));
root.dimensions.content = Rect {
x: 0.0,
y: 0.0,
width: 500.0,
height: 400.0,
};

let mut child = LayoutBox::new(BoxType::Block(1));
child.dimensions.content = Rect {
x: 50.0,
y: 50.0,
width: 200.0,
height: 100.0,
};

let mut link_box = LayoutBox::new_inline_link(
"Click here".to_string(),
"https://rust-lang.org".to_string(),
);
link_box.dimensions.content = Rect {
x: 60.0,
y: 60.0,
width: 80.0,
height: 20.0,
};

child.children.push(link_box);
root.children.push(child);

// Hit link box
assert_eq!(
root.hit_test_link(70.0, 65.0),
Some("https://rust-lang.org")
);

// Inside child but outside link box
assert_eq!(root.hit_test_link(180.0, 100.0), None);

// Outside root
assert_eq!(root.hit_test_link(600.0, 500.0), None);
}

#[test]
fn test_build_layout_tree_with_inline_hyperlinks() {
use crate::dom::build_dom;
use crate::html::Tokenizer;

let html = r#"<div>Click <a href="https://example.com/about">About Us</a> now!</div>"#;
let dom = build_dom(Tokenizer::new(html).tokenize());
let styles = HashMap::new();

let layout_tree =
build_layout_tree(&dom, &styles, dom.root).expect("Layout tree must build");

let div_box = &layout_tree.children[0];
assert_eq!(div_box.children.len(), 3);
assert_eq!(
div_box.children[1].text_content.as_deref(),
Some("About Us")
);
assert_eq!(
div_box.children[1].link_url.as_deref(),
Some("https://example.com/about")
);
assert_eq!(div_box.children[0].link_url, None);
assert_eq!(div_box.children[2].link_url, None);
}
}
73 changes: 68 additions & 5 deletions src/layout/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub struct LayoutBox {
pub children: Vec<LayoutBox>,
/// Only set on Anonymous boxes: the flattened inline text they contain.
pub text_content: Option<String>,
/// Hyperlink destination URL if this box represents an <a> anchor element.
pub link_url: Option<String>,
}

impl LayoutBox {
Expand All @@ -44,6 +46,7 @@ impl LayoutBox {
box_type,
children: vec![],
text_content: None,
link_url: None,
}
}
pub fn new_block(node_idx: usize) -> Self {
Expand All @@ -55,8 +58,50 @@ impl LayoutBox {
box_type: BoxType::Anonymous,
children: vec![],
text_content: Some(text),
link_url: None,
}
}
pub fn new_inline_link(text: String, href: String) -> Self {
LayoutBox {
dimensions: Dimensions::default(),
box_type: BoxType::Anonymous,
children: vec![],
text_content: Some(text),
link_url: Some(href),
}
}

/// Recursively performs 2D point hit-testing against this box and its children.
/// Traverses children in reverse document order (top-most rendered first)
/// and returns the innermost LayoutBox whose border-box contains (x, y).
pub fn hit_test(&self, x: f32, y: f32) -> Option<&LayoutBox> {
for child in self.children.iter().rev() {
if let Some(hit) = child.hit_test(x, y) {
return Some(hit);
}
}
if self.dimensions.border_box().contains(x, y) {
Some(self)
} else {
None
}
}

/// Recursively hit-tests this layout tree to find the hyperlink target URL (href)
/// at viewport coordinates (x, y).
pub fn hit_test_link(&self, x: f32, y: f32) -> Option<&str> {
for child in self.children.iter().rev() {
if let Some(url) = child.hit_test_link(x, y) {
return Some(url);
}
}
if self.dimensions.border_box().contains(x, y) {
if let Some(ref url) = self.link_url {
return Some(url.as_str());
}
}
None
}
}

fn is_block_level(dom: &Dom, node_idx: usize, styles: &HashMap<usize, ComputedStyle>) -> bool {
Expand Down Expand Up @@ -111,6 +156,15 @@ pub fn build_layout_tree(
}

let mut layout_box = LayoutBox::new_block(node_idx);

if let NodeType::Element(el) = &dom.nodes[node_idx].node_type {
if el.tag.eq_ignore_ascii_case("a") {
if let Some(href) = el.get_attribute("href") {
layout_box.link_url = Some(href.to_string());
}
}
}

let mut pending_inline = String::new();

for &child_idx in &dom.nodes[node_idx].children {
Expand All @@ -120,7 +174,7 @@ pub fn build_layout_tree(
pending_inline.push_str(t);
}
}
NodeType::Element(_) => {
NodeType::Element(el) => {
if is_display_none(child_idx, styles) {
continue;
}
Expand All @@ -129,6 +183,13 @@ pub fn build_layout_tree(
if let Some(child_box) = build_layout_tree(dom, styles, child_idx) {
layout_box.children.push(child_box);
}
} else if el.tag.eq_ignore_ascii_case("a") {
flush_inline_run(&mut pending_inline, &mut layout_box);
let mut link_text = String::new();
collect_inline_text(dom, child_idx, &mut link_text);
let href = el.get_attribute("href").unwrap_or("").to_string();
let link_box = LayoutBox::new_inline_link(link_text, href);
layout_box.children.push(link_box);
} else {
collect_inline_text(dom, child_idx, &mut pending_inline);
}
Expand All @@ -143,10 +204,12 @@ pub fn build_layout_tree(

fn flush_inline_run(pending_inline: &mut String, layout_box: &mut LayoutBox) {
if !pending_inline.trim().is_empty() {
layout_box
.children
.push(LayoutBox::new_anonymous_text(std::mem::take(pending_inline)));
let mut text_box = LayoutBox::new_anonymous_text(std::mem::take(pending_inline));
if layout_box.link_url.is_some() {
text_box.link_url = layout_box.link_url.clone();
}
layout_box.children.push(text_box);
} else {
pending_inline.clear();
}
}
}
Loading
Loading