diff --git a/ROADMAP.md b/ROADMAP.md index 5c2fbd9..f023a45 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -76,7 +76,8 @@ 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 +- [x] Interactive hyperlink navigation: click `` elements to navigate directly within active tab +- [x] Smooth mouse-wheel scrolling with inertia momentum and viewport clamping (Issue #3) - [ ] Per-site permission prompts (camera/mic/location) - [ ] Privacy dashboard (what's blocked, per-origin storage view) @@ -98,7 +99,7 @@ page rendering works, to keep motivation and momentum. 7. **Batch 7 (Multi-process IPC Security, SOP & CSP Enforcer)** — Completed 8. **Batch 8 (Browser Shell, Visual Multi-Tab Bar, Navigation History & Bookmarks)** — Completed -All **45 unit tests** are passing cleanly with 100% test suite success! +All **52 unit tests** are passing cleanly with 100% test suite success! diff --git a/src/layout/tree.rs b/src/layout/tree.rs index 50be438..fd7bdb7 100644 --- a/src/layout/tree.rs +++ b/src/layout/tree.rs @@ -1,13 +1,13 @@ -// Batch 3 — Layout tree construction +// Batch 3 — Layout tree construction // // Walks the styled DOM and produces a tree of LayoutBoxes: one Block box per // block-level element, and an "anonymous" box wrapping each run of inline -// content (text, inline elements) found between block-level children — +// content (text, inline elements) found between block-level children — // the same technique real engines use so inline content always has a box // to be laid out in, even when it has no element of its own. // // SIMPLIFICATION: inline elements (e.g. , ) are not yet given their -// own inline boxes — their text is flattened into the surrounding run. Proper +// own inline boxes — their text is flattened into the surrounding run. Proper // inline-level boxes (needed for e.g. styling just the text) are future // work once real text shaping (Batch 4+) is in place. @@ -102,6 +102,19 @@ impl LayoutBox { } None } + /// Returns the total rendered document height in CSS pixels. + /// Used to compute the maximum scroll offset: max_scroll = doc_height - viewport_height. + pub fn total_document_height(&self) -> f32 { + let mb = self.dimensions.margin_box(); + let mut max_y = mb.y + mb.height; + for child in &self.children { + let child_max = child.total_document_height(); + if child_max > max_y { + max_y = child_max; + } + } + max_y + } } fn is_block_level(dom: &Dom, node_idx: usize, styles: &HashMap) -> bool { diff --git a/src/main.rs b/src/main.rs index fcd5fee..8a3c0b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,7 +111,7 @@ fn main() { use std::sync::Arc; use winit::{ - event::{Event, WindowEvent}, + event::{Event, MouseScrollDelta, WindowEvent}, event_loop::EventLoop, keyboard::{Key, NamedKey}, window::WindowBuilder, @@ -135,6 +135,8 @@ fn main() { let mut devtools = ui::DevTools::new(); let mut mouse_pos = (0.0f32, 0.0f32); let mut current_layout_root: Option = None; + /// Total rendered document height in pixels (updated each RedrawRequested). + let mut document_height: f32 = 0.0; event_loop.run(move |event, elwt| { match event { @@ -152,6 +154,19 @@ fn main() { WindowEvent::CursorMoved { position, .. } => { mouse_pos = (position.x as f32, position.y as f32); } + WindowEvent::MouseWheel { delta, .. } => { + // Convert raw wheel delta to pixels. + // LineDelta: multiply by a comfortable line-height (40 px/line). + // PixelDelta: use the physical y component directly. + let delta_y = match delta { + MouseScrollDelta::LineDelta(_, rows) => rows * 40.0, + MouseScrollDelta::PixelDelta(pos) => pos.y as f32, + }; + let viewport_h = state.size.height as f32; + let max_scroll = (document_height - viewport_h).max(0.0); + tab_manager.active_tab_mut().apply_wheel_delta(delta_y, max_scroll); + state.window.request_redraw(); + } WindowEvent::MouseInput { state: element_state, button, .. } => { if element_state == winit::event::ElementState::Pressed { let (mx, my) = mouse_pos; @@ -464,9 +479,22 @@ fn main() { let viewport_width = state.size.width as f32; layout_tree(&mut layout_root, viewport_width, &styles); + // Update document height, clamp scroll, and advance the + // smooth-scroll animation. + document_height = layout_root.total_document_height(); + let viewport_h = state.size.height as f32; + let max_scroll = (document_height - viewport_h).max(0.0); + { + let tab = tab_manager.active_tab_mut(); + tab.clamp_scroll(max_scroll); + if tab.tick_scroll() { + state.window.request_redraw(); + } + } 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) { + let scroll_y = tab_manager.active_tab().scroll_y; + if let Err(e) = state.render(&paint_commands, scroll_y) { eprintln!("Render error: {:?}", e); } } diff --git a/src/ui/tab.rs b/src/ui/tab.rs index d5525f1..ff731d1 100644 --- a/src/ui/tab.rs +++ b/src/ui/tab.rs @@ -6,7 +6,12 @@ pub struct Tab { pub title: String, pub url: String, pub page_body: String, + /// Current rendered scroll offset (pixels from document top). pub scroll_y: f32, + /// Target scroll position; smooth interpolation converges toward this each frame. + pub target_scroll_y: f32, + /// Inertia velocity (pixels/frame) decayed each frame for momentum scrolling. + pub scroll_velocity: f32, pub zoom_level: f32, pub history: NavBarHistory, } @@ -26,7 +31,7 @@ impl Tab {

Modular Web Browser Engine Built from Scratch in Pure Rust | GPU Accelerated | Custom JS Runtime

-

SPEED DIAL & QUICK LINKS

+

SPEED DIAL & QUICK LINKS

[ Google ] [ GitHub ] @@ -40,6 +45,8 @@ impl Tab { "# ), scroll_y: 0.0, + target_scroll_y: 0.0, + scroll_velocity: 0.0, zoom_level: 1.0, history, } @@ -50,6 +57,8 @@ impl Tab { self.url = url.to_string(); self.page_body = body.to_string(); self.scroll_y = 0.0; + self.target_scroll_y = 0.0; + self.scroll_velocity = 0.0; self.history.navigate_to(url); } @@ -73,6 +82,48 @@ impl Tab { let body = self.page_body.to_ascii_lowercase(); body.matches(&q).count() } + + // ── Smooth scrolling ──────────────────────────────────────────────────────── + + /// Called by the `MouseWheel` event handler. `delta_y` is the raw + /// wheel delta in pixels (positive = scroll down, negative = scroll up). + /// `max_scroll` is the document height minus viewport height so we can + /// clamp the target immediately. + pub fn apply_wheel_delta(&mut self, delta_y: f32, max_scroll: f32) { + // Accumulate velocity from wheel input (scaled for comfortable speed) + self.scroll_velocity += delta_y * 0.8; + // Advance the target by the new velocity and clamp + self.target_scroll_y = + (self.target_scroll_y + self.scroll_velocity).clamp(0.0, max_scroll.max(0.0)); + } + + /// Called once per frame (on `RedrawRequested`). + /// Exponentially decays velocity and lerps `scroll_y` toward `target_scroll_y`. + /// Returns `true` if the frame needs a follow-up redraw (animation still running). + pub fn tick_scroll(&mut self) -> bool { + // Decay inertia — friction coefficient ~0.85 gives comfortable momentum + self.scroll_velocity *= 0.85; + if self.scroll_velocity.abs() < 0.05 { + self.scroll_velocity = 0.0; + } + + // Lerp toward target (easing factor 0.18 → snappy but smooth) + let diff = self.target_scroll_y - self.scroll_y; + if diff.abs() < 0.5 { + self.scroll_y = self.target_scroll_y; + return false; // settled — no further redraws needed + } + self.scroll_y += diff * 0.18; + true // still animating + } + + /// Hard-clamp both `scroll_y` and `target_scroll_y` to `[0, max_scroll]`. + /// Called whenever viewport size changes or a new page loads. + pub fn clamp_scroll(&mut self, max_scroll: f32) { + let upper = max_scroll.max(0.0); + self.target_scroll_y = self.target_scroll_y.clamp(0.0, upper); + self.scroll_y = self.scroll_y.clamp(0.0, upper); + } } pub struct TabManager { @@ -188,3 +239,78 @@ impl TabManager { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_scroll_clamp_prevents_negative() { + let mut tab = Tab::new(1, "Test", "https://example.com"); + tab.target_scroll_y = -50.0; + tab.scroll_y = -50.0; + tab.clamp_scroll(500.0); + assert_eq!(tab.scroll_y, 0.0); + assert_eq!(tab.target_scroll_y, 0.0); + } + + #[test] + fn test_scroll_clamp_prevents_overflow() { + let mut tab = Tab::new(1, "Test", "https://example.com"); + tab.target_scroll_y = 9999.0; + tab.scroll_y = 9999.0; + tab.clamp_scroll(800.0); + assert_eq!(tab.scroll_y, 800.0); + assert_eq!(tab.target_scroll_y, 800.0); + } + + #[test] + fn test_tick_scroll_lerps_toward_target() { + let mut tab = Tab::new(1, "Test", "https://example.com"); + tab.scroll_y = 0.0; + tab.target_scroll_y = 100.0; + let still_animating = tab.tick_scroll(); + assert!(still_animating); + // scroll_y should have moved toward 100 but not reached it yet + assert!(tab.scroll_y > 0.0 && tab.scroll_y < 100.0); + } + + #[test] + fn test_tick_scroll_settles_when_close() { + let mut tab = Tab::new(1, "Test", "https://example.com"); + tab.scroll_y = 99.6; + tab.target_scroll_y = 100.0; + let still_animating = tab.tick_scroll(); + assert!(!still_animating); + assert_eq!(tab.scroll_y, 100.0); + } + + #[test] + fn test_apply_wheel_delta_clamps_to_max() { + let mut tab = Tab::new(1, "Test", "https://example.com"); + tab.apply_wheel_delta(10_000.0, 500.0); + assert!(tab.target_scroll_y <= 500.0); + } + + #[test] + fn test_velocity_decays_to_zero() { + let mut tab = Tab::new(1, "Test", "https://example.com"); + tab.scroll_velocity = 100.0; + for _ in 0..60 { + tab.tick_scroll(); + } + assert!(tab.scroll_velocity.abs() < 0.1); + } + + #[test] + fn test_navigate_to_resets_scroll() { + let mut tab = Tab::new(1, "Test", "https://example.com"); + tab.scroll_y = 300.0; + tab.target_scroll_y = 300.0; + tab.scroll_velocity = 12.0; + tab.navigate_to("New Page", "https://new.com", "

hello

"); + assert_eq!(tab.scroll_y, 0.0); + assert_eq!(tab.target_scroll_y, 0.0); + assert_eq!(tab.scroll_velocity, 0.0); + } +}