git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commitbc571d121eeb5b087959bd082333379fe9c14643
authorLucas Galante <[email protected]>
date2026-08-10 18:10
cce-browser: Servo-embedded web browser MVP

Embeds Servo 0.4 (crates.io) with a SoftwareRenderingContext; finished
frames are read back and uploaded through cce-ui's image registry, drawn
as one quad under a 42px chrome bar (back/forward/reload, hand-rolled
URL editor with measured cursor, loading strip). Pointer/wheel/keys over
the page translate to Servo input events; Ctrl+L focuses the URL bar,
F5/Ctrl+R reloads, mouse Back/Forward traverse history. Page title
drives the toplevel title.

Note: workspace Cargo.lock pins primeorder 0.14.0-rc.14 (cargo update
--precise) — the 0.14.0 final breaks servo-script's p256/p384/p521 rcs.

Co-Authored-By: Claude Fable 5 <[email protected]>

 .gitignore     |   2 +
 Cargo.toml     |  16 ++
 src/main.rs    | 512 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/webview.rs | 231 ++++++++++++++++++++++++++
 4 files changed, 761 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..36c409f
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "cce-browser"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+cce-ui = { path = "../cce-ui" }
+calloop = "0.13.0"
+wayland-client = { version = "0.31", features = ["system"] }
+servo = "0.4"
+url = "2"
+dpi = "0.1"
+euclid = "0.22"
+rustls = { version = "0.23", features = ["aws-lc-rs"] }
+log = "0.4"
+env_logger = "0.11"
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..26cb670
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,512 @@
+//! cce-browser — a web browser on the embedded Servo engine.
+//!
+//! Servo renders pages into a CPU (software) rendering context; each
+//! finished frame is read back and uploaded to cce-ui's image registry,
+//! then drawn as a single quad under a thin chrome bar (back / forward /
+//! reload / URL field). Input over the page area is translated into Servo
+//! input events; the URL bar is a small hand-rolled line editor.
+
+mod webview;
+
+use url::Url;
+use wayland_client::QueueHandle;
+
+use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{DisplayList, PaintCtx};
+use cce_ui::widget::display::measure_text_width;
+use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
+
+use webview::ServoHost;
+
+const CHROME_H: f32 = 42.0;
+const BTN_W: f32 = 30.0;
+const BTN_H: f32 = 26.0;
+const BTN_GAP: f32 = 6.0;
+const PAD: f32 = 8.0;
+const URL_FONT: f32 = 14.0;
+const URL_PAD_X: f32 = 9.0;
+/// Pixels per wheel notch when the DE reports discrete line deltas.
+const LINE_PX: f64 = 76.0;
+
+const HOME_URL: &str = "https://servo.org";
+
+const CHROME_BG: [f32; 4] = [0.13, 0.14, 0.15, 1.0];
+const PAGE_BG: [f32; 4] = [0.10, 0.10, 0.11, 1.0];
+const FIELD_BG: [f32; 4] = [0.09, 0.09, 0.10, 1.0];
+const BTN_BG: [f32; 4] = [0.18, 0.19, 0.21, 1.0];
+const RIM: [f32; 4] = [0.22, 0.23, 0.25, 1.0];
+const RIM_FOCUS: [f32; 4] = [0.33, 0.48, 0.72, 1.0];
+const ACCENT: [f32; 4] = [0.35, 0.55, 0.85, 1.0];
+const TEXT: [u8; 3] = [220, 220, 225];
+const TEXT_DIM: [u8; 3] = [120, 122, 128];
+
+#[derive(Debug, Clone)]
+pub enum Message {
+    /// Servo requested an event-loop spin (waker or delegate signal).
+    Spin,
+}
+
+struct BrowserApp {
+    host: ServoHost,
+    win: (f32, f32),
+    scale: f64,
+    pointer: (f32, f32),
+    /// URL bar contents; mirrors the page URL unless the bar is focused.
+    url_input: String,
+    url_focused: bool,
+    /// Byte index of the URL-bar cursor.
+    cursor: usize,
+    loading: bool,
+    /// Page title; drives the toplevel title (the engine re-applies
+    /// `settings().title` whenever it changes).
+    title: Option<String>,
+}
+
+fn hit(r: &Rect, x: f32, y: f32) -> bool {
+    x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
+}
+
+fn btn_rect(i: usize) -> Rect {
+    Rect {
+        x: PAD + i as f32 * (BTN_W + BTN_GAP),
+        y: (CHROME_H - BTN_H) / 2.0,
+        width: BTN_W,
+        height: BTN_H,
+    }
+}
+
+fn url_rect(win_w: f32) -> Rect {
+    let x = PAD + 3.0 * (BTN_W + BTN_GAP) + 4.0;
+    Rect {
+        x,
+        y: (CHROME_H - BTN_H) / 2.0,
+        width: (win_w - x - PAD).max(60.0),
+        height: BTN_H,
+    }
+}
+
+/// Turn URL-bar input into something loadable: a real URL as-is, a bare
+/// host gets https://, anything else becomes a search.
+fn parse_url_input(input: &str) -> Option<Url> {
+    let s = input.trim();
+    if s.is_empty() {
+        return None;
+    }
+    if let Ok(u) = Url::parse(s) {
+        if matches!(u.scheme(), "http" | "https" | "file" | "data" | "about") {
+            return Some(u);
+        }
+    }
+    if !s.contains(' ') && s.contains('.') {
+        if let Ok(u) = Url::parse(&format!("https://{s}")) {
+            return Some(u);
+        }
+    }
+    let q: String = url::form_urlencoded::byte_serialize(s.as_bytes()).collect();
+    Url::parse(&format!("https://duckduckgo.com/html/?q={q}")).ok()
+}
+
+fn dom_button(button: MouseButton) -> Option<servo::MouseButton> {
+    match button {
+        MouseButton::Left => Some(servo::MouseButton::Left),
+        MouseButton::Right => Some(servo::MouseButton::Right),
+        MouseButton::Middle => Some(servo::MouseButton::Middle),
+        _ => None,
+    }
+}
+
+fn dom_key(key: &Key) -> Option<servo::Key> {
+    Some(match key {
+        Key::Character(s) => servo::Key::Character(s.clone()),
+        Key::Named(NamedKey::Space) => servo::Key::Character(" ".into()),
+        Key::Named(n) => servo::Key::Named(match n {
+            NamedKey::Backspace => servo::NamedKey::Backspace,
+            NamedKey::Tab => servo::NamedKey::Tab,
+            NamedKey::Enter => servo::NamedKey::Enter,
+            NamedKey::Escape => servo::NamedKey::Escape,
+            NamedKey::ArrowDown => servo::NamedKey::ArrowDown,
+            NamedKey::ArrowLeft => servo::NamedKey::ArrowLeft,
+            NamedKey::ArrowRight => servo::NamedKey::ArrowRight,
+            NamedKey::ArrowUp => servo::NamedKey::ArrowUp,
+            NamedKey::End => servo::NamedKey::End,
+            NamedKey::Home => servo::NamedKey::Home,
+            NamedKey::PageDown => servo::NamedKey::PageDown,
+            NamedKey::PageUp => servo::NamedKey::PageUp,
+            NamedKey::Delete => servo::NamedKey::Delete,
+            NamedKey::Control => servo::NamedKey::Control,
+            NamedKey::Shift => servo::NamedKey::Shift,
+            NamedKey::Alt => servo::NamedKey::Alt,
+            NamedKey::Super => servo::NamedKey::Meta,
+            NamedKey::F5 => servo::NamedKey::F5,
+            NamedKey::Space => unreachable!(),
+        }),
+    })
+}
+
+fn prev_boundary(s: &str, i: usize) -> usize {
+    let mut j = i;
+    while j > 0 {
+        j -= 1;
+        if s.is_char_boundary(j) {
+            return j;
+        }
+    }
+    0
+}
+
+fn next_boundary(s: &str, i: usize) -> usize {
+    let mut j = i;
+    while j < s.len() {
+        j += 1;
+        if s.is_char_boundary(j) {
+            return j;
+        }
+    }
+    s.len()
+}
+
+impl BrowserApp {
+    fn content_rect(&self) -> Rect {
+        Rect {
+            x: 0.0,
+            y: CHROME_H,
+            width: self.win.0,
+            height: (self.win.1 - CHROME_H).max(1.0),
+        }
+    }
+
+    fn content_px(&self) -> (u32, u32) {
+        let r = self.content_rect();
+        (
+            (r.width as f64 * self.scale) as u32,
+            (r.height as f64 * self.scale) as u32,
+        )
+    }
+
+    /// Pull delegate-observed page state into the chrome.
+    fn sync_page_state(&mut self) {
+        self.loading = self.host.loading();
+        self.title = self.host.title().filter(|t| !t.is_empty());
+        if !self.url_focused {
+            if let Some(u) = self.host.url() {
+                self.url_input = u.to_string();
+                self.cursor = self.url_input.len();
+            }
+        }
+    }
+
+    fn navigate(&mut self) {
+        if let Some(url) = parse_url_input(&self.url_input) {
+            self.host.load(url);
+            self.url_focused = false;
+            self.loading = true;
+        }
+    }
+
+    fn cursor_from_click(&self, click_x: f32, field: &Rect) -> usize {
+        let rel = click_x - field.x - URL_PAD_X;
+        let (sans, ..) = cce_ui::layout::read_preferred_fonts();
+        let mut i = 0;
+        while i < self.url_input.len() {
+            let next = next_boundary(&self.url_input, i);
+            if measure_text_width(&self.url_input[..next], &sans, URL_FONT) > rel {
+                return i;
+            }
+            i = next;
+        }
+        self.url_input.len()
+    }
+
+    fn edit_url(&mut self, event: &KeyEvent) {
+        match &event.logical_key {
+            Key::Named(NamedKey::Enter) => self.navigate(),
+            Key::Named(NamedKey::Escape) => {
+                self.url_focused = false;
+                self.sync_page_state();
+            }
+            Key::Named(NamedKey::Backspace) => {
+                if self.cursor > 0 {
+                    let prev = prev_boundary(&self.url_input, self.cursor);
+                    self.url_input.replace_range(prev..self.cursor, "");
+                    self.cursor = prev;
+                }
+            }
+            Key::Named(NamedKey::Delete) => {
+                if self.cursor < self.url_input.len() {
+                    let next = next_boundary(&self.url_input, self.cursor);
+                    self.url_input.replace_range(self.cursor..next, "");
+                }
+            }
+            Key::Named(NamedKey::ArrowLeft) => self.cursor = prev_boundary(&self.url_input, self.cursor),
+            Key::Named(NamedKey::ArrowRight) => self.cursor = next_boundary(&self.url_input, self.cursor),
+            Key::Named(NamedKey::Home) => self.cursor = 0,
+            Key::Named(NamedKey::End) => self.cursor = self.url_input.len(),
+            Key::Character(c) if event.ctrl => {
+                if c == "u" {
+                    self.url_input.clear();
+                    self.cursor = 0;
+                }
+            }
+            _ => {
+                let insert = match (&event.text, &event.logical_key) {
+                    (Some(t), _) if !event.ctrl && !t.chars().any(char::is_control) => Some(t.clone()),
+                    (None, Key::Named(NamedKey::Space)) => Some(" ".to_string()),
+                    (None, Key::Character(c)) if !event.ctrl => Some(c.clone()),
+                    _ => None,
+                };
+                if let Some(t) = insert {
+                    self.url_input.insert_str(self.cursor, &t);
+                    self.cursor += t.len();
+                }
+            }
+        }
+    }
+}
+
+impl Application for BrowserApp {
+    type Message = Message;
+
+    fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
+        let url = Url::parse(HOME_URL).expect("home url");
+        let host = ServoHost::new(sender, url, (1200, (800.0 - CHROME_H) as u32));
+        Self {
+            host,
+            win: (1200.0, 800.0),
+            scale: 1.0,
+            pointer: (0.0, 0.0),
+            url_input: HOME_URL.to_string(),
+            url_focused: false,
+            cursor: HOME_URL.len(),
+            loading: true,
+            title: None,
+        }
+    }
+
+    fn settings(&self) -> WindowSettings {
+        WindowSettings {
+            title: self.title.clone().unwrap_or_else(|| "Browser".to_string()),
+            app_id: "cce-browser".to_string(),
+            width: 1200,
+            height: 800,
+            fullscreen: false,
+            min_size: Some((480, 320)),
+        }
+    }
+
+    fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
+        match msg {
+            Message::Spin => {
+                let (new_frame, dirty) = self.host.pump();
+                if dirty {
+                    self.sync_page_state();
+                }
+                if new_frame || dirty {
+                    *needs_rebuild = true;
+                }
+            }
+        }
+    }
+
+    fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
+
+    fn handle_resize(&mut self, width: f32, height: f32, scale: f64) {
+        self.win = (width, height);
+        self.scale = scale;
+        let (w, h) = self.content_px();
+        self.host.resize(w, h, scale as f32);
+    }
+
+    fn handle_pointer_move(&mut self, pos: LogicalPosition, _needs_rebuild: &mut bool) {
+        self.pointer = (pos.x, pos.y);
+        if pos.y >= CHROME_H {
+            let s = self.scale as f32;
+            self.host.mouse_move(pos.x * s, (pos.y - CHROME_H) * s);
+        }
+    }
+
+    fn handle_mouse_input(
+        &mut self,
+        button: MouseButton,
+        state: ElementState,
+        pos: LogicalPosition,
+        needs_rebuild: &mut bool,
+    ) -> Option<Self::Message> {
+        let pressed = state == ElementState::Pressed;
+
+        if pos.y < CHROME_H {
+            if !pressed || button != MouseButton::Left {
+                return None;
+            }
+            *needs_rebuild = true;
+            if hit(&btn_rect(0), pos.x, pos.y) {
+                self.host.back();
+            } else if hit(&btn_rect(1), pos.x, pos.y) {
+                self.host.forward();
+            } else if hit(&btn_rect(2), pos.x, pos.y) {
+                self.host.reload();
+            } else {
+                let field = url_rect(self.win.0);
+                if hit(&field, pos.x, pos.y) {
+                    self.cursor = self.cursor_from_click(pos.x, &field);
+                    self.url_focused = true;
+                } else {
+                    self.url_focused = false;
+                }
+            }
+            return None;
+        }
+
+        // Page area: a click dismisses URL-bar focus, then goes to the page.
+        if self.url_focused && pressed {
+            self.url_focused = false;
+            self.sync_page_state();
+            *needs_rebuild = true;
+        }
+        match button {
+            MouseButton::Back if pressed => self.host.back(),
+            MouseButton::Forward if pressed => self.host.forward(),
+            _ => {
+                if let Some(b) = dom_button(button) {
+                    let s = self.scale as f32;
+                    self.host.mouse_button(b, pressed, pos.x * s, (pos.y - CHROME_H) * s);
+                }
+            }
+        }
+        None
+    }
+
+    fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, _needs_rebuild: &mut bool) {
+        if pos.y < CHROME_H {
+            return;
+        }
+        // cce-ui deltas are winit-signed (positive = scroll up); the DOM and
+        // Servo's Scroll::Delta want positive = reveal content below.
+        let (dx, dy) = match delta {
+            MouseScrollDelta::LineDelta(x, y) => (-(*x as f64) * LINE_PX, -(*y as f64) * LINE_PX),
+            MouseScrollDelta::PixelDelta(p) => (-p.x, -p.y),
+        };
+        let s = self.scale;
+        self.host.wheel(
+            dx * s,
+            dy * s,
+            pos.x * s as f32,
+            (pos.y - CHROME_H) * s as f32,
+        );
+    }
+
+    fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
+        if self.url_focused {
+            if event.state == ElementState::Pressed {
+                self.edit_url(event);
+                *needs_rebuild = true;
+            }
+            return None;
+        }
+
+        if event.state == ElementState::Pressed {
+            if event.ctrl {
+                if let Key::Character(c) = &event.logical_key {
+                    match c.as_str() {
+                        "l" => {
+                            self.url_focused = true;
+                            self.cursor = self.url_input.len();
+                            *needs_rebuild = true;
+                            return None;
+                        }
+                        "r" => {
+                            self.host.reload();
+                            return None;
+                        }
+                        _ => {}
+                    }
+                }
+            }
+            if event.logical_key == Key::Named(NamedKey::F5) {
+                self.host.reload();
+                return None;
+            }
+        }
+
+        if let Some(k) = dom_key(&event.logical_key) {
+            self.host.key(k, event.state == ElementState::Pressed);
+        }
+        None
+    }
+
+    fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
+        self.win = (size.width, size.height);
+        let mut pc = PaintCtx::new();
+        let w = size.width;
+
+        // Page.
+        let content = self.content_rect();
+        pc.quad(content, PAGE_BG);
+        if let Some((id, ..)) = self.host.image() {
+            pc.image(id, content, 1.0);
+        } else {
+            pc.text("Loading...", content.x + 16.0, content.y + 18.0, 13.0, TEXT_DIM);
+        }
+
+        // Chrome bar.
+        pc.quad(Rect { x: 0.0, y: 0.0, width: w, height: CHROME_H }, CHROME_BG);
+        if self.loading {
+            pc.quad(Rect { x: 0.0, y: CHROME_H - 2.0, width: w, height: 2.0 }, ACCENT);
+        }
+
+        let labels = ["<", ">", "R"];
+        let enabled = [self.host.can_go_back(), self.host.can_go_forward(), true];
+        for (i, label) in labels.iter().enumerate() {
+            let r = btn_rect(i);
+            pc.rounded_rect(r, 6.0, (true, true, true, true), BTN_BG);
+            let color = if enabled[i] { TEXT } else { TEXT_DIM };
+            let (sans, ..) = cce_ui::layout::read_preferred_fonts();
+            let lw = measure_text_width(label, &sans, 14.0);
+            pc.text(
+                *label,
+                r.x + (r.width - lw) / 2.0,
+                cce_ui::layout::align_text_y(r.y, r.height, 14.0, 0.0),
+                14.0,
+                color,
+            );
+        }
+
+        // URL field: rim + recess, brighter rim when focused.
+        let f = url_rect(w);
+        let rim = if self.url_focused { RIM_FOCUS } else { RIM };
+        pc.rounded_rect(
+            Rect { x: f.x - 1.0, y: f.y - 1.0, width: f.width + 2.0, height: f.height + 2.0 },
+            7.0,
+            (true, true, true, true),
+            rim,
+        );
+        pc.rounded_rect(f, 6.0, (true, true, true, true), FIELD_BG);
+        let ty = cce_ui::layout::align_text_y(f.y, f.height, URL_FONT, 0.0);
+        pc.clip(f, |pc| {
+            pc.text(self.url_input.clone(), f.x + URL_PAD_X, ty, URL_FONT, TEXT);
+            if self.url_focused {
+                let (sans, ..) = cce_ui::layout::read_preferred_fonts();
+                let cx = f.x + URL_PAD_X + measure_text_width(&self.url_input[..self.cursor], &sans, URL_FONT);
+                pc.quad(
+                    Rect { x: cx, y: f.y + 4.0, width: 1.0, height: f.height - 8.0 },
+                    [0.85, 0.87, 0.92, 1.0],
+                );
+            }
+        });
+
+        Some(pc.finish())
+    }
+
+    fn display_list_text(&self) -> bool {
+        true
+    }
+
+    fn clear_color(&self) -> [f32; 4] {
+        CHROME_BG
+    }
+}
+
+fn main() {
+    env_logger::init();
+    cce_ui::engine::run::<BrowserApp>();
+}
diff --git a/src/webview.rs b/src/webview.rs
new file mode 100644
index 0000000..985c347
--- /dev/null
+++ b/src/webview.rs
@@ -0,0 +1,231 @@
+//! Servo embedding host: boots an in-process Servo against a software
+//! (CPU) rendering context, owns the single WebView, and pumps finished
+//! frames into cce-ui's image registry as RGBA uploads.
+//!
+//! Everything here lives on the main thread. Servo wakes the calloop loop
+//! through `Waker` (a channel sender); the app then calls [`ServoHost::pump`],
+//! which spins Servo's event loop and, when the delegate has flagged a ready
+//! frame, paints and reads back pixels. `read_to_image` happens *without*
+//! `present()` so the buffer is still there to read.
+
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
+
+use dpi::PhysicalSize;
+use euclid::Scale;
+use servo::{
+    DeviceIntRect, DevicePoint, DeviceVector2D, EventLoopWaker, InputEvent, Key as DomKey,
+    KeyState, KeyboardEvent, LoadStatus, MouseButton as DomMouseButton, MouseButtonAction,
+    MouseButtonEvent, MouseMoveEvent, RenderingContext, Scroll, Servo, ServoBuilder,
+    SoftwareRenderingContext, WebView, WebViewBuilder, WebViewDelegate, WheelDelta, WheelEvent,
+    WheelMode,
+};
+use url::Url;
+
+use crate::Message;
+
+/// Page state observed by the delegate, polled by the app after each pump.
+#[derive(Default)]
+pub struct PageState {
+    frame_ready: Cell<bool>,
+    dirty: Cell<bool>,
+    title: RefCell<Option<String>>,
+    url: RefCell<Option<Url>>,
+    loading: Cell<bool>,
+}
+
+struct Delegate {
+    state: Rc<PageState>,
+    wake: calloop::channel::Sender<Message>,
+}
+
+impl Delegate {
+    fn touch(&self) {
+        self.state.dirty.set(true);
+        let _ = self.wake.send(Message::Spin);
+    }
+}
+
+impl WebViewDelegate for Delegate {
+    fn notify_new_frame_ready(&self, _webview: WebView) {
+        self.state.frame_ready.set(true);
+        self.touch();
+    }
+
+    fn notify_page_title_changed(&self, _webview: WebView, title: Option<String>) {
+        *self.state.title.borrow_mut() = title;
+        self.touch();
+    }
+
+    fn notify_url_changed(&self, _webview: WebView, url: Url) {
+        *self.state.url.borrow_mut() = Some(url);
+        self.touch();
+    }
+
+    fn notify_load_status_changed(&self, _webview: WebView, status: LoadStatus) {
+        self.state.loading.set(status != LoadStatus::Complete);
+        self.touch();
+    }
+}
+
+/// Wakes the calloop event loop from Servo's internal threads.
+#[derive(Clone)]
+struct Waker(calloop::channel::Sender<Message>);
+
+impl EventLoopWaker for Waker {
+    fn clone_box(&self) -> Box<dyn EventLoopWaker> {
+        Box::new(self.clone())
+    }
+
+    fn wake(&self) {
+        let _ = self.0.send(Message::Spin);
+    }
+}
+
+pub struct ServoHost {
+    servo: Servo,
+    webview: WebView,
+    context: Rc<SoftwareRenderingContext>,
+    state: Rc<PageState>,
+    /// Current page frame in the cce-ui image registry: (id, w px, h px).
+    image: Option<(u32, u32, u32)>,
+}
+
+impl ServoHost {
+    pub fn new(wake: calloop::channel::Sender<Message>, url: Url, size_px: (u32, u32)) -> Self {
+        // Servo's TLS stack looks up the process-wide rustls crypto provider.
+        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
+
+        let context = Rc::new(
+            SoftwareRenderingContext::new(PhysicalSize::new(size_px.0.max(1), size_px.1.max(1)))
+                .expect("create software rendering context"),
+        );
+        context
+            .make_current()
+            .expect("make software rendering context current");
+
+        let servo = ServoBuilder::default()
+            .event_loop_waker(Box::new(Waker(wake.clone())))
+            .build();
+
+        let state = Rc::new(PageState::default());
+        let webview = WebViewBuilder::new(&servo, context.clone())
+            .url(url)
+            .delegate(Rc::new(Delegate { state: state.clone(), wake }))
+            .build();
+        webview.show();
+        webview.focus();
+
+        Self { servo, webview, context, state, image: None }
+    }
+
+    /// Spin Servo and swap any finished frame into the image registry.
+    /// Returns (new frame uploaded, page state changed).
+    pub fn pump(&mut self) -> (bool, bool) {
+        self.servo.spin_event_loop();
+        let dirty = self.state.dirty.take();
+        let mut new_frame = false;
+        if self.state.frame_ready.take() {
+            self.webview.paint();
+            let rect = DeviceIntRect::from_size(self.context.size2d().to_i32());
+            if let Some(img) = self.context.read_to_image(rect) {
+                let (w, h) = img.dimensions();
+                let id = cce_ui::vk::upload_rgba(img.into_raw(), w, h);
+                if let Some((old, ..)) = self.image.replace((id, w, h)) {
+                    cce_ui::vk::free_image(old);
+                }
+                new_frame = true;
+            }
+        }
+        (new_frame, dirty)
+    }
+
+    pub fn image(&self) -> Option<(u32, u32, u32)> {
+        self.image
+    }
+
+    pub fn title(&self) -> Option<String> {
+        self.state.title.borrow().clone()
+    }
+
+    pub fn url(&self) -> Option<Url> {
+        self.state.url.borrow().clone()
+    }
+
+    pub fn loading(&self) -> bool {
+        self.state.loading.get()
+    }
+
+    pub fn can_go_back(&self) -> bool {
+        self.webview.can_go_back()
+    }
+
+    pub fn can_go_forward(&self) -> bool {
+        self.webview.can_go_forward()
+    }
+
+    pub fn load(&self, url: Url) {
+        self.webview.load(url);
+    }
+
+    pub fn reload(&self) {
+        self.webview.reload();
+    }
+
+    pub fn back(&self) {
+        if self.webview.can_go_back() {
+            let _ = self.webview.go_back(1);
+        }
+    }
+
+    pub fn forward(&self) {
+        if self.webview.can_go_forward() {
+            let _ = self.webview.go_forward(1);
+        }
+    }
+
+    /// Resize the webview (and its rendering context) to a physical size.
+    pub fn resize(&self, width_px: u32, height_px: u32, scale: f32) {
+        self.webview.set_hidpi_scale_factor(Scale::new(scale));
+        self.webview
+            .resize(PhysicalSize::new(width_px.max(1), height_px.max(1)));
+    }
+
+    /// Pointer position in device pixels relative to the webview origin.
+    pub fn mouse_move(&self, x_px: f32, y_px: f32) {
+        let _ = self.webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(
+            DevicePoint::new(x_px, y_px).into(),
+        )));
+    }
+
+    pub fn mouse_button(&self, button: DomMouseButton, pressed: bool, x_px: f32, y_px: f32) {
+        let action = if pressed { MouseButtonAction::Down } else { MouseButtonAction::Up };
+        let _ = self.webview.notify_input_event(InputEvent::MouseButton(MouseButtonEvent::new(
+            action,
+            button,
+            DevicePoint::new(x_px, y_px).into(),
+        )));
+    }
+
+    /// Wheel/scroll in device pixels, DOM sign convention (positive y
+    /// reveals content below). Sends both the DOM wheel event and the
+    /// compositor scroll.
+    pub fn wheel(&self, dx_px: f64, dy_px: f64, x_px: f32, y_px: f32) {
+        let point = DevicePoint::new(x_px, y_px);
+        let _ = self.webview.notify_input_event(InputEvent::Wheel(WheelEvent::new(
+            WheelDelta { x: dx_px, y: dy_px, z: 0.0, mode: WheelMode::DeltaPixel },
+            point.into(),
+        )));
+        self.webview.notify_scroll_event(
+            Scroll::Delta(DeviceVector2D::new(dx_px as f32, dy_px as f32).into()),
+            point.into(),
+        );
+    }
+
+    pub fn key(&self, key: DomKey, pressed: bool) {
+        let state = if pressed { KeyState::Down } else { KeyState::Up };
+        let _ = self
+            .webview
+            .notify_input_event(InputEvent::Keyboard(KeyboardEvent::from_state_and_key(state, key)));
+    }
+}