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 @@ -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 `<a>` elements to navigate directly within active tab
- [x] Interactive hyperlink navigation: click `<a>` 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)

Expand All @@ -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!



Expand Down
19 changes: 16 additions & 3 deletions src/layout/tree.rs
Original file line number Diff line number Diff line change
@@ -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. <span>, <a>) 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 <a> text) are future
// work once real text shaping (Batch 4+) is in place.

Expand Down Expand Up @@ -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<usize, ComputedStyle>) -> bool {
Expand Down
32 changes: 30 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<layout::LayoutBox> = 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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
Expand Down
128 changes: 127 additions & 1 deletion src/ui/tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -26,7 +31,7 @@ impl Tab {
<p style="color: #9ca3af; font-size: 15px; margin-bottom: 24px;">Modular Web Browser Engine Built from Scratch in Pure Rust | GPU Accelerated | Custom JS Runtime</p>

<div class="card" style="background-color: #111827; border-radius: 12px; padding: 20px; border: 1px solid #1f2937; margin-bottom: 16px;">
<p style="color: #38bdf8; font-weight: bold; font-size: 16px; margin-bottom: 10px;">SPEED DIAL & QUICK LINKS</p>
<p style="color: #38bdf8; font-weight: bold; font-size: 16px; margin-bottom: 10px;">SPEED DIAL &amp; QUICK LINKS</p>
<p style="color: #e2e8f0; font-size: 14px; margin: 8px 0;">
<a href="https://google.com" style="color: #38bdf8; margin: 0 6px;">[ Google ]</a>
<a href="https://github.com" style="color: #38bdf8; margin: 0 6px;">[ GitHub ]</a>
Expand All @@ -40,6 +45,8 @@ impl Tab {
"#
),
scroll_y: 0.0,
target_scroll_y: 0.0,
scroll_velocity: 0.0,
zoom_level: 1.0,
history,
}
Expand All @@ -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);
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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", "<p>hello</p>");
assert_eq!(tab.scroll_y, 0.0);
assert_eq!(tab.target_scroll_y, 0.0);
assert_eq!(tab.scroll_velocity, 0.0);
}
}
Loading