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

commit05809e83f940e10c42d5d20c4f1f4b719ba90fac
parentc6e0895943
authorLucas Galante <[email protected]>
date2026-08-11 11:07
Add tabs

One Servo WebView per tab, all sharing the software rendering context;
only the active tab paints and reads back (servoshell's model), and
each tab keeps its last frame in the image registry so switching is
instant. Delegate signals route per WebViewId. The floating bar grows
a second row: tab pills with width-fitted titles, close x (or middle
click), per-tab loading strip, and a + button that opens about:blank
with the URL bar focused. Ctrl+T / Ctrl+W / Ctrl+(Shift+)Tab; closing
the last tab exits.

Live-verified: + opens a tab, typed navigation, Ctrl+Tab switches with
the cached frame shown instantly, close x prunes the strip, titles
track per tab.

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

 src/main.rs    | 216 ++++++++++++++++++++++++++++++++++++++++--
 src/webview.rs | 291 ++++++++++++++++++++++++++++++++++++++++++---------------
 2 files changed, 423 insertions(+), 84 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 87d2c11..12779f9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -20,9 +20,19 @@ use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta,
 use webview::ServoHost;
 
 const BAR_MARGIN: f32 = 10.0;
-const BAR_H: f32 = 38.0;
+/// Two rows: tab strip on top, nav controls + URL field below.
+const BAR_H: f32 = BAR_PAD + TAB_H + ROW_GAP + BTN_H + BAR_PAD;
 const BAR_RADIUS: f32 = 10.0;
 const BAR_PAD: f32 = 7.0;
+const TAB_H: f32 = 24.0;
+const TAB_GAP: f32 = 4.0;
+const TAB_MIN_W: f32 = 56.0;
+const TAB_MAX_W: f32 = 200.0;
+/// Tabs at least this wide get a close "x" region on their right edge.
+const TAB_CLOSE_MIN_W: f32 = 72.0;
+const TAB_CLOSE_W: f32 = 18.0;
+const PLUS_W: f32 = 26.0;
+const ROW_GAP: f32 = 6.0;
 /// Utility-bar fill; the negative alpha marks the plate as blur-behind.
 /// The blurred page is the base and this color tints it at |alpha|
 /// opacity — keep |alpha| low so the frosted content shows through.
@@ -40,6 +50,8 @@ const HOME_URL: &str = "https://servo.org";
 const PAGE_BG: [f32; 4] = [0.10, 0.10, 0.11, 1.0];
 const FIELD_BG: [f32; 4] = [0.09, 0.09, 0.10, 0.40];
 const BTN_BG: [f32; 4] = [0.20, 0.21, 0.23, 0.40];
+const TAB_BG: [f32; 4] = [0.15, 0.16, 0.18, 0.30];
+const TAB_ACTIVE_BG: [f32; 4] = [0.32, 0.34, 0.38, 0.55];
 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];
@@ -50,6 +62,8 @@ const TEXT_DIM: [u8; 3] = [120, 122, 128];
 pub enum Message {
     /// Servo requested an event-loop spin (waker or delegate signal).
     Spin,
+    /// Last tab closed: exit the app.
+    Quit,
 }
 
 struct BrowserApp {
@@ -82,10 +96,52 @@ fn bar_rect(win_w: f32) -> Rect {
     }
 }
 
+/// Y of the tab-strip row.
+fn tabs_y() -> f32 {
+    BAR_MARGIN + BAR_PAD
+}
+
+/// Y of the nav-controls row.
+fn controls_y() -> f32 {
+    BAR_MARGIN + BAR_PAD + TAB_H + ROW_GAP
+}
+
+fn plus_rect(win_w: f32) -> Rect {
+    let bar = bar_rect(win_w);
+    Rect {
+        x: bar.x + bar.width - BAR_PAD - PLUS_W,
+        y: tabs_y(),
+        width: PLUS_W,
+        height: TAB_H,
+    }
+}
+
+fn tab_rect(win_w: f32, count: usize, i: usize) -> Rect {
+    let bar = bar_rect(win_w);
+    let avail = bar.width - 2.0 * BAR_PAD - PLUS_W - TAB_GAP - (count.max(1) - 1) as f32 * TAB_GAP;
+    let w = (avail / count.max(1) as f32).clamp(TAB_MIN_W, TAB_MAX_W);
+    Rect {
+        x: bar.x + BAR_PAD + i as f32 * (w + TAB_GAP),
+        y: tabs_y(),
+        width: w,
+        height: TAB_H,
+    }
+}
+
+/// The close "x" hit region on a tab pill, when the pill is wide enough.
+fn tab_close_rect(pill: &Rect) -> Option<Rect> {
+    (pill.width >= TAB_CLOSE_MIN_W).then(|| Rect {
+        x: pill.x + pill.width - TAB_CLOSE_W,
+        y: pill.y,
+        width: TAB_CLOSE_W,
+        height: pill.height,
+    })
+}
+
 fn btn_rect(i: usize) -> Rect {
     Rect {
         x: BAR_MARGIN + BAR_PAD + i as f32 * (BTN_W + BTN_GAP),
-        y: BAR_MARGIN + (BAR_H - BTN_H) / 2.0,
+        y: controls_y(),
         width: BTN_W,
         height: BTN_H,
     }
@@ -96,7 +152,7 @@ fn url_rect(win_w: f32) -> Rect {
     let x = BAR_MARGIN + BAR_PAD + 3.0 * (BTN_W + BTN_GAP) + 4.0;
     Rect {
         x,
-        y: BAR_MARGIN + (BAR_H - BTN_H) / 2.0,
+        y: controls_y(),
         width: (bar.x + bar.width - BAR_PAD - x).max(60.0),
         height: BTN_H,
     }
@@ -197,7 +253,8 @@ impl BrowserApp {
         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();
+                let s = u.to_string();
+                self.url_input = if s == "about:blank" { String::new() } else { s };
                 self.cursor = self.url_input.len();
             }
         }
@@ -211,6 +268,48 @@ impl BrowserApp {
         }
     }
 
+    /// New blank tab with the URL bar focused for typing.
+    fn new_tab(&mut self) {
+        let url = Url::parse("about:blank").expect("about:blank");
+        self.host.open_tab(url);
+        self.url_input.clear();
+        self.cursor = 0;
+        self.url_focused = true;
+        self.sync_page_state();
+    }
+
+    /// Close a tab; returns `Message::Quit` when it was the last one.
+    fn close_tab(&mut self, index: usize) -> Option<Message> {
+        if !self.host.close_tab(index) {
+            return Some(Message::Quit);
+        }
+        self.url_focused = false;
+        self.sync_page_state();
+        None
+    }
+
+    fn switch_tab(&mut self, index: usize) {
+        self.host.activate(index);
+        self.url_focused = false;
+        self.sync_page_state();
+    }
+
+    /// Widest prefix of `text` fitting `avail`, with a "…"-style tail cut.
+    fn fit_text(text: &str, sans: &str, size: f32, avail: f32) -> String {
+        if measure_text_width(text, sans, size) <= avail {
+            return text.to_string();
+        }
+        let mut end = text.len();
+        while end > 0 {
+            end = prev_boundary(text, end);
+            let cut = format!("{}...", &text[..end]);
+            if measure_text_width(&cut, sans, size) <= avail {
+                return cut;
+            }
+        }
+        String::new()
+    }
+
     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();
@@ -301,7 +400,7 @@ impl Application for BrowserApp {
         }
     }
 
-    fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
+    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();
@@ -312,6 +411,7 @@ impl Application for BrowserApp {
                     *needs_rebuild = true;
                 }
             }
+            Message::Quit => *exit = true,
         }
     }
 
@@ -342,11 +442,31 @@ impl Application for BrowserApp {
         let pressed = state == ElementState::Pressed;
 
         if hit(&bar_rect(self.win.0), pos.x, pos.y) {
-            if !pressed || button != MouseButton::Left {
+            if !pressed || !matches!(button, MouseButton::Left | MouseButton::Middle) {
                 return None;
             }
             *needs_rebuild = true;
-            if hit(&btn_rect(0), pos.x, pos.y) {
+            // Tab strip: activate / close (x region or middle click) / new tab.
+            let count = self.host.tab_count();
+            for i in 0..count {
+                let pill = tab_rect(self.win.0, count, i);
+                if !hit(&pill, pos.x, pos.y) {
+                    continue;
+                }
+                let on_close =
+                    tab_close_rect(&pill).is_some_and(|r| hit(&r, pos.x, pos.y));
+                if button == MouseButton::Middle || on_close {
+                    return self.close_tab(i);
+                }
+                self.switch_tab(i);
+                return None;
+            }
+            if button != MouseButton::Left {
+                return None;
+            }
+            if hit(&plus_rect(self.win.0), pos.x, pos.y) {
+                self.new_tab();
+            } else 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();
@@ -399,6 +519,30 @@ impl Application for BrowserApp {
     }
 
     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
+        // Tab shortcuts work regardless of URL-bar focus.
+        if event.state == ElementState::Pressed && event.ctrl {
+            let count = self.host.tab_count();
+            match &event.logical_key {
+                Key::Character(c) if c == "t" => {
+                    self.new_tab();
+                    *needs_rebuild = true;
+                    return None;
+                }
+                Key::Character(c) if c == "w" => {
+                    *needs_rebuild = true;
+                    return self.close_tab(self.host.active_index());
+                }
+                Key::Named(NamedKey::Tab) if count > 1 => {
+                    let cur = self.host.active_index();
+                    let next = if event.shift { (cur + count - 1) % count } else { (cur + 1) % count };
+                    self.switch_tab(next);
+                    *needs_rebuild = true;
+                    return None;
+                }
+                _ => {}
+            }
+        }
+
         if self.url_focused {
             if event.state == ElementState::Pressed {
                 self.edit_url(event);
@@ -464,6 +608,64 @@ impl Application for BrowserApp {
             });
         }
 
+        // Tab strip.
+        let (sans, ..) = cce_ui::layout::read_preferred_fonts();
+        let count = self.host.tab_count();
+        let active = self.host.active_index();
+        for i in 0..count {
+            let pill = tab_rect(w, count, i);
+            let is_active = i == active;
+            pc.rounded_rect(
+                pill,
+                7.0,
+                (true, true, true, true),
+                if is_active { TAB_ACTIVE_BG } else { TAB_BG },
+            );
+            let tab = self.host.tab(i);
+            let title = tab
+                .and_then(|t| t.title.clone().filter(|s| !s.is_empty()))
+                .or_else(|| tab.and_then(|t| t.url.clone()).map(|u| u.to_string()))
+                .filter(|s| s != "about:blank")
+                .unwrap_or_else(|| "New Tab".to_string());
+            let close = tab_close_rect(&pill);
+            let text_avail = pill.width - 16.0 - close.map_or(0.0, |_| TAB_CLOSE_W - 4.0);
+            let label = Self::fit_text(&title, &sans, 12.0, text_avail);
+            let color = if is_active { TEXT } else { TEXT_DIM };
+            pc.text(
+                label,
+                pill.x + 8.0,
+                cce_ui::layout::align_text_y(pill.y, pill.height, 12.0, 0.0),
+                12.0,
+                color,
+            );
+            if tab.is_some_and(|t| t.loading) {
+                pc.quad(
+                    Rect { x: pill.x, y: pill.y + pill.height - 2.0, width: pill.width, height: 2.0 },
+                    ACCENT,
+                );
+            }
+            if let Some(cr) = close {
+                let xw = measure_text_width("x", &sans, 11.0);
+                pc.text(
+                    "x",
+                    cr.x + (cr.width - xw) / 2.0 - 2.0,
+                    cce_ui::layout::align_text_y(cr.y, cr.height, 11.0, 0.0),
+                    11.0,
+                    TEXT_DIM,
+                );
+            }
+        }
+        let plus = plus_rect(w);
+        pc.rounded_rect(plus, 7.0, (true, true, true, true), BTN_BG);
+        let pw = measure_text_width("+", &sans, 14.0);
+        pc.text(
+            "+",
+            plus.x + (plus.width - pw) / 2.0,
+            cce_ui::layout::align_text_y(plus.y, plus.height, 14.0, 0.0),
+            14.0,
+            TEXT,
+        );
+
         let labels = ["<", ">", "R"];
         let enabled = [self.host.can_go_back(), self.host.can_go_forward(), true];
         for (i, label) in labels.iter().enumerate() {
diff --git a/src/webview.rs b/src/webview.rs
index c565f08..a101b1d 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -1,14 +1,17 @@
 //! 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.
+//! (CPU) rendering context and owns one WebView per tab, all sharing that
+//! context — only the active tab is painted and read back (servoshell's
+//! model). Finished frames upload into cce-ui's image registry; each tab
+//! keeps its last frame so switching is instant.
 //!
 //! 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.
+//! frame on the active tab, 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::collections::HashMap;
 use std::rc::Rc;
 
 use dpi::PhysicalSize;
@@ -17,53 +20,56 @@ use servo::{
     DeviceIntRect, DevicePoint, EventLoopWaker, InputEvent, Key as DomKey, KeyState,
     KeyboardEvent, LoadStatus, MouseButton as DomMouseButton, MouseButtonAction, MouseButtonEvent,
     MouseMoveEvent, RenderingContext, Servo, ServoBuilder, SoftwareRenderingContext, WebView,
-    WebViewBuilder, WebViewDelegate, WheelDelta, WheelEvent, WheelMode,
+    WebViewBuilder, WebViewDelegate, WebViewId, WheelDelta, WheelEvent, WheelMode,
 };
 use url::Url;
 
 use crate::Message;
 
-/// Page state observed by the delegate, polled by the app after each pump.
+/// Delegate-observed signals for one webview, polled by the app after each
+/// pump.
 #[derive(Default)]
-pub struct PageState {
-    frame_ready: Cell<bool>,
+struct TabSignals {
+    frame_ready: bool,
+    title: Option<String>,
+    url: Option<Url>,
+    loading: bool,
+}
+
+#[derive(Default)]
+struct HostShared {
     dirty: Cell<bool>,
-    title: RefCell<Option<String>>,
-    url: RefCell<Option<Url>>,
-    loading: Cell<bool>,
+    per: RefCell<HashMap<WebViewId, TabSignals>>,
 }
 
 struct Delegate {
-    state: Rc<PageState>,
+    shared: Rc<HostShared>,
     wake: calloop::channel::Sender<Message>,
 }
 
 impl Delegate {
-    fn touch(&self) {
-        self.state.dirty.set(true);
+    fn with_tab(&self, webview: &WebView, f: impl FnOnce(&mut TabSignals)) {
+        f(self.shared.per.borrow_mut().entry(webview.id()).or_default());
+        self.shared.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_new_frame_ready(&self, webview: WebView) {
+        self.with_tab(&webview, |t| t.frame_ready = true);
     }
 
-    fn notify_page_title_changed(&self, _webview: WebView, title: Option<String>) {
-        *self.state.title.borrow_mut() = title;
-        self.touch();
+    fn notify_page_title_changed(&self, webview: WebView, title: Option<String>) {
+        self.with_tab(&webview, |t| t.title = title);
     }
 
-    fn notify_url_changed(&self, _webview: WebView, url: Url) {
-        *self.state.url.borrow_mut() = Some(url);
-        self.touch();
+    fn notify_url_changed(&self, webview: WebView, url: Url) {
+        self.with_tab(&webview, |t| t.url = Some(url));
     }
 
-    fn notify_load_status_changed(&self, _webview: WebView, status: LoadStatus) {
-        self.state.loading.set(status != LoadStatus::Complete);
-        self.touch();
+    fn notify_load_status_changed(&self, webview: WebView, status: LoadStatus) {
+        self.with_tab(&webview, |t| t.loading = status != LoadStatus::Complete);
     }
 }
 
@@ -81,13 +87,25 @@ impl EventLoopWaker for Waker {
     }
 }
 
+/// One tab: its webview plus the app-visible page state and the last frame
+/// uploaded to the image registry (id, w px, h px).
+pub struct Tab {
+    webview: WebView,
+    pub title: Option<String>,
+    pub url: Option<Url>,
+    pub loading: bool,
+    image: Option<(u32, u32, u32)>,
+}
+
 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)>,
+    shared: Rc<HostShared>,
+    delegate: Rc<Delegate>,
+    tabs: Vec<Tab>,
+    active: usize,
+    size_px: (u32, u32),
+    scale: f32,
 }
 
 impl ServoHost {
@@ -107,103 +125,221 @@ impl ServoHost {
             .event_loop_waker(Box::new(Waker(wake.clone())))
             .build();
 
-        let state = Rc::new(PageState::default());
-        let webview = WebViewBuilder::new(&servo, context.clone())
+        let shared = Rc::new(HostShared::default());
+        let delegate = Rc::new(Delegate { shared: shared.clone(), wake });
+
+        let mut host = Self {
+            servo,
+            context,
+            shared,
+            delegate,
+            tabs: Vec::new(),
+            // Sentinel so the first open_tab's activate() does the full
+            // show/focus/resize dance instead of early-returning on 0 == 0.
+            active: usize::MAX,
+            size_px,
+            scale: 1.0,
+        };
+        host.open_tab(url);
+        host
+    }
+
+    fn build_webview(&self, url: Url) -> WebView {
+        WebViewBuilder::new(&self.servo, self.context.clone())
             .url(url)
-            .delegate(Rc::new(Delegate { state: state.clone(), wake }))
-            .build();
-        webview.show();
-        webview.focus();
+            .delegate(self.delegate.clone())
+            .build()
+    }
+
+    /// Open a new tab and make it active.
+    pub fn open_tab(&mut self, url: Url) {
+        let webview = self.build_webview(url);
+        self.tabs.push(Tab {
+            webview,
+            title: None,
+            url: None,
+            loading: true,
+            image: None,
+        });
+        self.activate(self.tabs.len() - 1);
+    }
 
-        Self { servo, webview, context, state, image: None }
+    /// Close a tab. Returns false when that was the last tab (the app should
+    /// exit; the tab is gone either way).
+    pub fn close_tab(&mut self, index: usize) -> bool {
+        if index >= self.tabs.len() {
+            return true;
+        }
+        let was_active = index == self.active;
+        let old_active = self.active;
+        let tab = self.tabs.remove(index);
+        self.shared.per.borrow_mut().remove(&tab.webview.id());
+        if let Some((id, ..)) = tab.image {
+            cce_ui::vk::free_image(id);
+        }
+        drop(tab); // last WebView handle: servo tears the page down
+        if self.tabs.is_empty() {
+            return false;
+        }
+        // Closing the active tab moves to its neighbor; closing a background
+        // tab keeps the current one (its index may have shifted down).
+        let next = if was_active {
+            index.min(self.tabs.len() - 1)
+        } else if old_active > index {
+            old_active - 1
+        } else {
+            old_active
+        };
+        self.active = usize::MAX; // force activate() to do the work
+        self.activate(next);
+        true
     }
 
-    /// Spin Servo and swap any finished frame into the image registry.
-    /// Returns (new frame uploaded, page state changed).
+    /// Make tab `index` the visible, focused one.
+    pub fn activate(&mut self, index: usize) {
+        if index >= self.tabs.len() || index == self.active {
+            return;
+        }
+        if let Some(old) = self.tabs.get(self.active) {
+            old.webview.blur();
+            old.webview.hide();
+        }
+        self.active = index;
+        let tab = &self.tabs[index];
+        tab.webview.show();
+        tab.webview.focus();
+        tab.webview.set_hidpi_scale_factor(Scale::new(self.scale));
+        tab.webview
+            .resize(PhysicalSize::new(self.size_px.0.max(1), self.size_px.1.max(1)));
+        // Composite whatever frame the tab already has so the switch shows
+        // content immediately; the resize above refreshes it right after.
+        self.paint_active();
+    }
+
+    pub fn tab_count(&self) -> usize {
+        self.tabs.len()
+    }
+
+    pub fn active_index(&self) -> usize {
+        self.active
+    }
+
+    pub fn tab(&self, index: usize) -> Option<&Tab> {
+        self.tabs.get(index)
+    }
+
+    fn active_tab(&self) -> &Tab {
+        &self.tabs[self.active]
+    }
+
+    /// Paint the active webview into the shared context and swap the read
+    /// pixels into its registry image.
+    fn paint_active(&mut self) {
+        self.active_tab().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);
+            let tab = &mut self.tabs[self.active];
+            if let Some((old, ..)) = tab.image.replace((id, w, h)) {
+                cce_ui::vk::free_image(old);
+            }
+        }
+    }
+
+    /// Spin Servo, sync delegate signals into tabs, and repaint the active
+    /// tab if it produced a frame. Returns (new frame, any state change).
     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);
+        let dirty = self.shared.dirty.take();
+        let mut active_frame = false;
+        if dirty {
+            let mut per = self.shared.per.borrow_mut();
+            for (i, tab) in self.tabs.iter_mut().enumerate() {
+                if let Some(sig) = per.get_mut(&tab.webview.id()) {
+                    tab.title = sig.title.clone();
+                    tab.url = sig.url.clone();
+                    tab.loading = sig.loading;
+                    if std::mem::take(&mut sig.frame_ready) && i == self.active {
+                        active_frame = true;
+                    }
                 }
-                new_frame = true;
             }
         }
-        (new_frame, dirty)
+        if active_frame {
+            self.paint_active();
+        }
+        (active_frame, dirty)
     }
 
     pub fn image(&self) -> Option<(u32, u32, u32)> {
-        self.image
+        self.active_tab().image
     }
 
     pub fn title(&self) -> Option<String> {
-        self.state.title.borrow().clone()
+        self.active_tab().title.clone()
     }
 
     pub fn url(&self) -> Option<Url> {
-        self.state.url.borrow().clone()
+        self.active_tab().url.clone()
     }
 
     pub fn loading(&self) -> bool {
-        self.state.loading.get()
+        self.active_tab().loading
     }
 
     pub fn can_go_back(&self) -> bool {
-        self.webview.can_go_back()
+        self.active_tab().webview.can_go_back()
     }
 
     pub fn can_go_forward(&self) -> bool {
-        self.webview.can_go_forward()
+        self.active_tab().webview.can_go_forward()
     }
 
     pub fn load(&self, url: Url) {
-        self.webview.load(url);
+        self.active_tab().webview.load(url);
     }
 
     pub fn reload(&self) {
-        self.webview.reload();
+        self.active_tab().webview.reload();
     }
 
     pub fn back(&self) {
-        if self.webview.can_go_back() {
-            let _ = self.webview.go_back(1);
+        let webview = &self.active_tab().webview;
+        if webview.can_go_back() {
+            let _ = webview.go_back(1);
         }
     }
 
     pub fn forward(&self) {
-        if self.webview.can_go_forward() {
-            let _ = self.webview.go_forward(1);
+        let webview = &self.active_tab().webview;
+        if webview.can_go_forward() {
+            let _ = 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)));
+    /// Resize the active webview (and the shared rendering context) to a
+    /// physical size. Inactive tabs are brought up to size on activation.
+    pub fn resize(&mut self, width_px: u32, height_px: u32, scale: f32) {
+        self.size_px = (width_px, height_px);
+        self.scale = scale;
+        let webview = &self.active_tab().webview;
+        webview.set_hidpi_scale_factor(Scale::new(scale));
+        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(),
-        )));
+        let _ = self.active_tab().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(),
-        )));
+        let _ = self.active_tab().webview.notify_input_event(InputEvent::MouseButton(
+            MouseButtonEvent::new(action, button, DevicePoint::new(x_px, y_px).into()),
+        ));
     }
 
     /// Wheel in device pixels, winit sign convention (positive y = scroll
@@ -211,7 +347,7 @@ impl ServoHost {
     /// and applies the inverted delta as the scroll itself — no separate
     /// scroll event wanted.
     pub fn wheel(&self, dx_px: f64, dy_px: f64, x_px: f32, y_px: f32) {
-        let _ = self.webview.notify_input_event(InputEvent::Wheel(WheelEvent::new(
+        let _ = self.active_tab().webview.notify_input_event(InputEvent::Wheel(WheelEvent::new(
             WheelDelta { x: dx_px, y: dy_px, z: 0.0, mode: WheelMode::DeltaPixel },
             DevicePoint::new(x_px, y_px).into(),
         )));
@@ -220,6 +356,7 @@ impl ServoHost {
     pub fn key(&self, key: DomKey, pressed: bool) {
         let state = if pressed { KeyState::Down } else { KeyState::Up };
         let _ = self
+            .active_tab()
             .webview
             .notify_input_event(InputEvent::Keyboard(KeyboardEvent::from_state_and_key(state, key)));
     }