diff --git a/ROADMAP.md b/ROADMAP.md index 10e739b..5c2fbd9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 (``, ``, ...) don't get their own boxes yet — - their text is flattened into the surrounding run +- [x] Inline `` link elements: bounding box geometry computation, 2D hit-testing, and interactive hyperlink navigation +- [ ] Remaining inline elements (``, ``, ...) flattened into surrounding runs ### Batch 4 — Paint + Compositor (1-1.5 months) done - [x] Layout tree → paint command list (rects, text runs, images) @@ -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 `` elements to navigate directly within active tab - [ ] Per-site permission prompts (camera/mic/location) - [ ] Privacy dashboard (what's blocked, per-origin storage view) diff --git a/src/layout/box_model.rs b/src/layout/box_model.rs index 23135ff..e6d9788 100644 --- a/src/layout/box_model.rs +++ b/src/layout/box_model.rs @@ -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)] @@ -47,4 +51,26 @@ impl Dimensions { pub fn margin_box(&self) -> Rect { self.border_box().expanded_by(self.margin) } -} \ No newline at end of file +} + +#[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)); + } +} diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 83da19a..8429e88 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -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#"
Click About Us now!
"#; + 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); + } } diff --git a/src/layout/tree.rs b/src/layout/tree.rs index 731eba1..50be438 100644 --- a/src/layout/tree.rs +++ b/src/layout/tree.rs @@ -35,6 +35,8 @@ pub struct LayoutBox { pub children: Vec, /// Only set on Anonymous boxes: the flattened inline text they contain. pub text_content: Option, + /// Hyperlink destination URL if this box represents an anchor element. + pub link_url: Option, } impl LayoutBox { @@ -44,6 +46,7 @@ impl LayoutBox { box_type, children: vec![], text_content: None, + link_url: None, } } pub fn new_block(node_idx: usize) -> Self { @@ -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) -> bool { @@ -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 { @@ -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; } @@ -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); } @@ -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(); } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index e225290..fcd5fee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,18 +10,12 @@ mod security; mod style; mod ui; - - - - use css::{CssParser, CssTokenizer}; use dom::build_dom; use html::Tokenizer; use layout::{build_layout_tree, layout_tree}; -use style::compute_styles; use render::RenderState; - - +use style::compute_styles; pub fn fetch_and_render_page(url_str: &str) -> (dom::Dom, Vec) { let resolved_url = net::format_url_or_search_query(url_str); @@ -78,7 +72,9 @@ pub fn fetch_and_render_page(url_str: &str) -> (dom::Dom, Vec = None; event_loop.run(move |event, elwt| { match event { @@ -222,6 +219,45 @@ fn main() { } else if my >= 35.0 && my <= 75.0 { address_bar.set_text(""); state.window.request_redraw(); + } else if my > 75.0 { + // 2D Point hit-testing against hyperlink bounding boxes + let content_y = my + tab_manager.active_tab().scroll_y; + if let Some(ref ltree) = current_layout_root { + if let Some(href) = ltree.hit_test_link(mx, content_y) { + let current_base = tab_manager.active_tab().url.clone(); + let target_url = net::resolve_url(¤t_base, href); + println!("Hyperlink clicked: '{}' -> '{}'", href, target_url); + address_bar.set_text(&target_url); + let active_title = target_url.clone(); + let body = match net::fetch(&target_url) { + Ok(resp) => { + let raw_body = resp.body_as_string(); + let mut temp_dom = build_dom(Tokenizer::new(&raw_body).tokenize()); + let res = net::discover_resources(&temp_dom, &target_url); + let external_css = net::ResourceLoader::fetch_all_stylesheets(&res.stylesheets); + + let scripts = js::JsRuntime::extract_scripts(&temp_dom); + if !scripts.is_empty() { + let mut runtime = js::JsRuntime::new(); + for s in &scripts { + let _ = runtime.execute(s, &mut temp_dom); + } + } + + if !external_css.is_empty() { + format!("{}", external_css, raw_body) + } else { + raw_body + } + } + Err(err) => { + format!("

Fetch Error

{}

", err) + } + }; + tab_manager.active_tab_mut().navigate_to(&active_title, &target_url, &body); + state.window.request_redraw(); + } + } } } } @@ -427,8 +463,9 @@ fn main() { if let Some(mut layout_root) = build_layout_tree(&dom_tree, &styles, dom_tree.root) { let viewport_width = state.size.width as f32; layout_tree(&mut layout_root, viewport_width, &styles); - + let paint_commands = paint::build_display_list(&layout_root, &styles); + current_layout_root = Some(layout_root); if let Err(e) = state.render(&paint_commands, active_tab.scroll_y) { eprintln!("Render error: {:?}", e); } @@ -456,7 +493,10 @@ fn print_layout_box(lb: &layout::LayoutBox, depth: usize) { println!( "{indent}Text(\"{}\") x={:.0} y={:.0} w={:.0} h={:.0}", lb.text_content.as_deref().unwrap_or(""), - d.x, d.y, d.width, d.height + d.x, + d.y, + d.width, + d.height ); } } @@ -594,10 +634,16 @@ mod css_tests { let sheet = parse("div p { color: blue; } div > span { color: green; }"); let descendant_selector = &sheet.rules[0].selectors[0]; assert_eq!(descendant_selector.steps.len(), 2); - assert_eq!(descendant_selector.steps[1].0, Some(super::css::Combinator::Descendant)); + assert_eq!( + descendant_selector.steps[1].0, + Some(super::css::Combinator::Descendant) + ); let child_selector = &sheet.rules[1].selectors[0]; - assert_eq!(child_selector.steps[1].0, Some(super::css::Combinator::Child)); + assert_eq!( + child_selector.steps[1].0, + Some(super::css::Combinator::Child) + ); } #[test] @@ -767,7 +813,10 @@ mod batch3_tests { let span_idx = dom.nodes[div_idx].children[0]; let deep_em_idx = dom.nodes[span_idx].children[0]; let direct_em_idx = dom.nodes[div_idx].children[1]; - assert!(styles.get(&deep_em_idx).and_then(|s| s.get("color")).is_none()); + assert!(styles + .get(&deep_em_idx) + .and_then(|s| s.get("color")) + .is_none()); assert_eq!( styles.get(&direct_em_idx).and_then(|s| s.get("color")), Some(&"red".to_string()) @@ -823,4 +872,4 @@ mod batch3_tests { } } } -} \ No newline at end of file +} diff --git a/src/paint/mod.rs b/src/paint/mod.rs index 88e8ada..38f74c6 100644 --- a/src/paint/mod.rs +++ b/src/paint/mod.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::layout::{LayoutBox, BoxType, Rect}; +use crate::layout::{BoxType, LayoutBox, Rect}; use crate::style::ComputedStyle; #[derive(Debug, Clone, Copy, PartialEq)] @@ -21,15 +21,30 @@ impl Color { } pub fn black() -> Self { - Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 } + Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + } } pub fn white() -> Self { - Color { r: 1.0, g: 1.0, b: 1.0, a: 1.0 } + Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + } } pub fn transparent() -> Self { - Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } + Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.0, + } } } @@ -159,7 +174,8 @@ fn build_recursive( // 0. Draw Box Shadow (Elevation) if let Some(s) = style { if let Some(_shadow_str) = s.get("box-shadow") { - let shadow_color = parse_color("rgba(0, 0, 0, 0.35)").unwrap_or(Color::new(0.0, 0.0, 0.0, 0.35)); + let shadow_color = parse_color("rgba(0, 0, 0, 0.35)") + .unwrap_or(Color::new(0.0, 0.0, 0.0, 0.35)); *order += 1; items.push(LayeredCommand { cmd: PaintCommand::DrawRect { @@ -202,7 +218,11 @@ fn build_recursive( // 2. Draw Borders let border_width = lb.dimensions.border; - if border_width.top > 0.0 || border_width.bottom > 0.0 || border_width.left > 0.0 || border_width.right > 0.0 { + if border_width.top > 0.0 + || border_width.bottom > 0.0 + || border_width.left > 0.0 + || border_width.right > 0.0 + { let border_color_str = style.and_then(|s| s.get("border-color")); let color = border_color_str .and_then(|c| parse_color(c)) @@ -290,10 +310,17 @@ fn build_recursive( } BoxType::Anonymous => { if let Some(text) = &lb.text_content { - let color = parent_style - .and_then(|s| s.get("color")) - .and_then(|c| parse_color(c)) - .unwrap_or(Color::black()); + let color = if lb.link_url.is_some() { + parent_style + .and_then(|s| s.get("color")) + .and_then(|c| parse_color(c)) + .unwrap_or(Color::new(0.24, 0.55, 0.95, 1.0)) + } else { + parent_style + .and_then(|s| s.get("color")) + .and_then(|c| parse_color(c)) + .unwrap_or(Color::black()) + }; let font_size = parent_style .and_then(|s| s.get("font-size")) @@ -309,7 +336,8 @@ fn build_recursive( let char_width = 8.0 * (font_size / 16.0); let line_height = 18.0 * (font_size / 16.0); - let lines = crate::layout::engine::wrap_text(text, lb.dimensions.content.width, char_width); + let lines = + crate::layout::engine::wrap_text(text, lb.dimensions.content.width, char_width); for (idx, line) in lines.into_iter().enumerate() { *order += 1; @@ -349,14 +377,25 @@ mod tests { #[test] fn parses_rgb_color_function() { - assert_eq!(parse_color("rgb(255, 0, 0)"), Some(Color::new(1.0, 0.0, 0.0, 1.0))); - assert_eq!(parse_color("rgba(0, 128, 255, 0.5)"), Some(Color::new(0.0, 128.0 / 255.0, 1.0, 0.5))); + assert_eq!( + parse_color("rgb(255, 0, 0)"), + Some(Color::new(1.0, 0.0, 0.0, 1.0)) + ); + assert_eq!( + parse_color("rgba(0, 128, 255, 0.5)"), + Some(Color::new(0.0, 128.0 / 255.0, 1.0, 0.5)) + ); } #[test] fn test_draw_image_paint_command() { let cmd = PaintCommand::DrawImage { - rect: Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 }, + rect: Rect { + x: 0.0, + y: 0.0, + width: 100.0, + height: 100.0, + }, src: "logo.png".to_string(), alt: "Logo".to_string(), is_fixed: false, @@ -404,7 +443,11 @@ mod tests { let list = build_display_list(&root, &styles); // child 2 (z-index 1) must be rendered before child 1 (z-index 10) assert_eq!(list.len(), 2); - if let (PaintCommand::DrawRect { color: c1, .. }, PaintCommand::DrawRect { color: c2, .. }) = (&list[0], &list[1]) { + if let ( + PaintCommand::DrawRect { color: c1, .. }, + PaintCommand::DrawRect { color: c2, .. }, + ) = (&list[0], &list[1]) + { assert_eq!(*c1, Color::new(0.0, 0.0, 1.0, 1.0)); // blue assert_eq!(*c2, Color::new(1.0, 0.0, 0.0, 1.0)); // red } diff --git a/src/ui/tab.rs b/src/ui/tab.rs index b7b94d9..d5525f1 100644 --- a/src/ui/tab.rs +++ b/src/ui/tab.rs @@ -27,8 +27,14 @@ impl Tab {
"# @@ -88,7 +94,11 @@ impl TabManager { pub fn new_tab(&mut self, title: &str, url: &str) -> usize { let id = self.next_id; self.next_id += 1; - let tab_title = if title.is_empty() { format!("Tab {}", id) } else { title.to_string() }; + let tab_title = if title.is_empty() { + format!("Tab {}", id) + } else { + title.to_string() + }; self.tabs.push(Tab::new(id, &tab_title, url)); self.active_index = self.tabs.len() - 1; id @@ -118,13 +128,19 @@ impl TabManager { } pub fn render_tab_strip_html(&self) -> String { - let mut html = String::from(r#"
"#); + let mut html = String::from( + r#"
"#, + ); for (i, tab) in self.tabs.iter().enumerate() { let is_active = i == self.active_index; let bg_color = if is_active { "#111827" } else { "#0b0f19" }; let text_color = if is_active { "#38bdf8" } else { "#9ca3af" }; - let border = if is_active { "1px solid #374151" } else { "1px solid transparent" }; - + let border = if is_active { + "1px solid #374151" + } else { + "1px solid transparent" + }; + html.push_str(&format!( r#"[{}] {} [x]"#, bg_color, text_color, border, i + 1, tab.title