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

commite50b7284a4d6e2ab3f9e03149ee6c3abf04d8cee
parent598a82942d
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 19:37
feat: middle-click opens a background tab

Every "open this in a new tab" that is not an ask to go somewhere now
opens behind the page: a middle-clicked page link, a middle-clicked
favorite or bookmark-menu row, and the context menu's "Open Link in
New Tab". The active tab, its focus and the URL bar stay put.

Page links are caught in WebKit's decide-policy: a link-click (or
target=_blank new-window) navigation carrying button 2 is ignored and
its URL queued for the chrome, which opens it with
open_background_tab -- a webview left unmapped, as a switched-away tab
is. Everything else gets WebKit's default.

So the new tab is seen to arrive, a folded bar peeks for 1.5s and
folds again. Any press on the bar or explicit open makes it the
person's; a pointer brought onto it holds the fold off; a page click
does not fold a peek, so a run of middle-clicks keeps it out rather
than flapping it. Ctrl+T, "+" and external opens still open in front.

On the retired Servo backend open_background_tab is an open followed
by switching back, and page middle-clicks are not caught.
examples/wpe_middle.rs proves the engine side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md              |  34 ++++++++++++--
 Cargo.toml             |   4 ++
 examples/wpe_middle.rs | 125 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs            |  90 ++++++++++++++++++++++++++++++-----
 src/webview.rs         |   7 +++
 src/wpe/host.rs        |  69 ++++++++++++++++++++++++++-
 6 files changed, 312 insertions(+), 17 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 5557fe4..81f26fa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -298,8 +298,8 @@ archive: everything worth finding again, newest first. **Favorites**
 `about:favorites`, `Ctrl+Shift+B`) are the handful of places worth a
 permanent one-click spot: a **strip of label pills inside the bar**, between
 the tab row and the controls row. Click loads the favorite in the active tab
-and folds the bar (a menu pick); middle-click opens it in a new tab and
-leaves the bar out. Insertion order is strip order; the page reorders
+and folds the bar (a menu pick); middle-click opens it in a background tab
+and leaves the bar out. Insertion order is strip order; the page reorders
 (▲/▼), renames (a GET form per row — form submissions reach the `cce:`
 handler like any other navigation) and removes.
 
@@ -310,8 +310,8 @@ bookmarks menu: the star is *this* page's bookmark, the button beside it is
 all of them. A **search field** on top, then three sections — add/remove
 this page, the saved pages themselves (newest first, the `cce://bookmarks`
 order), and `Manage Bookmarks (n)` which hands the collection to that page. A row visits
-in the active tab and folds everything away, middle-click opens it in a new
-tab and leaves the menu up, and the **remove "x"** on the hovered row prunes
+in the active tab and folds everything away, middle-click opens it in a
+background tab and leaves the menu up, and the **remove "x"** on the hovered row prunes
 in place. It closes on Escape (ahead of the URL bar and the page), on a
 click anywhere off its plate, and with the bar it hangs from.
 
@@ -413,6 +413,14 @@ Menu semantics, all in `handle_mouse_input` / `handle_key_input`:
   unfocused (the first Escape in a focused field only drops focus, as
   before); submitting a URL; picking a tab. Closing a tab does *not* fold —
   several often go in a row.
+- **Peek**: a tab opening in the background (`open_background_tab`) unfolds a
+  folded bar for `CHROME_PEEK` (1.5s), so the new tab is seen landing in the
+  strip, then folds it again. `chrome_peek` is the deadline; a thread sends
+  `Spin` when it passes (an idle page turns no loop) and `settle_peek` folds.
+  Any press on the bar, or any explicit open, makes it the person's and it
+  stays; a pointer *brought onto* it holds the fold off; a page click does
+  not fold a peek, so a run of middle-clicked links keeps it out instead of
+  flapping it. A bar the person already had open is left alone.
 - Folding drops URL-bar focus (`close_chrome`), so an off-screen field never
   keeps eating keystrokes. Wheel and pointer moves over the chrome stay off the
   page, gated by `chrome_hit`, not `bar_rect`.
@@ -453,6 +461,24 @@ holds the `WebKitOptionMenu` and hands the chrome an `OptionMenuInfo`;
 `examples/wpe_options.rs` proves the engine side against the real engine.
 
 ### Middle-click is a background tab
+
+Every "open this in a new tab" that is not the person asking for a fresh tab
+opens **behind** the page: a middle-clicked link in a page, a middle-clicked
+favorite or bookmark-menu row, and the context menu's "Open Link in New Tab".
+The active tab, its focus and the URL bar stay as they were, and the bar
+peeks (above). `Ctrl+T`, the "+" and a forwarded external open still open in
+front — those are asks to *go* somewhere.
+
+A page link reaches the chrome through WebKit's `decide-policy`
+(`on_decide_policy`): a link-click navigation — or new-window one, for
+`target=_blank` — carrying mouse button 2 is ignored and its URL queued
+(`take_background_opens`, drained in the `Spin` handler). Everything else gets
+WebKit's default. A background webview is left unmapped and invisible, the
+state a switched-away tab is in. `examples/wpe_middle.rs` proves the engine
+side (both link kinds diverted, left-click undisturbed, the tab loading
+behind the page). On the retired Servo backend `open_background_tab` is an
+open followed by switching back, and page middle-clicks are not caught.
+
 ### Key routing
 
 `handle_key_input` is a three-stage funnel and the order is load-bearing: Ctrl chords
diff --git a/Cargo.toml b/Cargo.toml
index 27567e7..81cd3a3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -98,6 +98,10 @@ required-features = ["wpe"]
 name = "wpe_ctx"
 required-features = ["wpe"]
 
+[[example]]
+name = "wpe_middle"
+required-features = ["wpe"]
+
 [[example]]
 name = "wpe_options"
 required-features = ["wpe"]
diff --git a/examples/wpe_middle.rs b/examples/wpe_middle.rs
new file mode 100644
index 0000000..1355e3a
--- /dev/null
+++ b/examples/wpe_middle.rs
@@ -0,0 +1,125 @@
+//! A middle-clicked link becomes a background tab, end to end.
+//!
+//! Serves a page with an ordinary link and a `target=_blank` one, and checks
+//! against the real engine:
+//!
+//! * a middle-click on either is diverted — queued for a background tab —
+//!   and the page it was clicked on stays where it was;
+//! * a left-click on the ordinary link still navigates, undiverted;
+//! * `open_background_tab` adds a tab that loads while the active one
+//!   stays active.
+//!
+//! `cce-shadow --instance <n> run ./target/release/examples/wpe_middle`
+
+#[cfg(not(feature = "wpe"))]
+fn main() {
+    eprintln!("build with --features wpe");
+}
+
+#[cfg(feature = "wpe")]
+#[derive(Debug, Clone, Copy)]
+pub enum EditingCommand { Copy, Cut, Paste }
+
+#[cfg(feature = "wpe")]
+#[path = "../src/pages.rs"]
+mod pages;
+#[cfg(feature = "wpe")]
+#[path = "../src/downloads.rs"]
+mod downloads;
+#[cfg(feature = "wpe")]
+#[path = "../src/wpe/mod.rs"]
+mod wpe;
+
+#[cfg(feature = "wpe")]
+const PAGE: &str = "<!doctype html><title>links</title>\
+<style>a{position:absolute;left:20px;width:200px;height:40px;display:block}</style>\
+<a id=plain href=/plain style=top:20px>plain</a>\
+<a id=blank href=/blank target=_blank style=top:100px>blank</a>";
+
+#[cfg(feature = "wpe")]
+fn serve() -> u16 {
+    use std::io::{Read, Write};
+    let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+    let port = listener.local_addr().unwrap().port();
+    std::thread::spawn(move || {
+        for stream in listener.incoming() {
+            let Ok(mut s) = stream else { continue };
+            let mut buf = [0u8; 4096];
+            let n = s.read(&mut buf).unwrap_or(0);
+            let req = String::from_utf8_lossy(&buf[..n]).to_string();
+            let path = req.split_whitespace().nth(1).unwrap_or("/").to_string();
+            let body = if path == "/" {
+                PAGE.to_string()
+            } else {
+                format!("<!doctype html><title>{}</title>", &path[1..])
+            };
+            let _ = write!(
+                s,
+                "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+                body.len(),
+                body
+            );
+        }
+    });
+    port
+}
+
+#[cfg(feature = "wpe")]
+fn main() {
+    use cce_ui::widget::MouseButton;
+    let port = serve();
+    let base = format!("http://127.0.0.1:{port}");
+    let mut host = wpe::WebKitHost::new(url::Url::parse(&format!("{base}/")).unwrap(), (1200, 800));
+    let settle = |h: &mut wpe::WebKitHost, n: u32| {
+        for _ in 0..n {
+            h.pump();
+            std::thread::sleep(std::time::Duration::from_millis(50));
+        }
+    };
+    let click = |h: &mut wpe::WebKitHost, b: MouseButton, x: f32, y: f32| {
+        h.mouse_move(x, y);
+        h.mouse_button_ui(b, true, x, y);
+        h.mouse_button_ui(b, false, x, y);
+    };
+    settle(&mut host, 30);
+    println!("loaded: {:?}", host.title());
+    let mut ok = true;
+    let mut check = |name: &str, pass: bool| {
+        println!("  {name:<36} {}", if pass { "OK" } else { "FAIL" });
+        ok &= pass;
+    };
+
+    click(&mut host, MouseButton::Middle, 60.0, 40.0);
+    settle(&mut host, 10);
+    let plain = host.take_background_opens();
+    println!("middle plain -> {plain:?}, title {:?}", host.title());
+    check("middle-click plain link is queued", plain.iter().any(|u| u.path() == "/plain"));
+    check("  and the page stays", host.title().as_deref() == Some("links"));
+
+    click(&mut host, MouseButton::Middle, 60.0, 120.0);
+    settle(&mut host, 10);
+    let blank = host.take_background_opens();
+    println!("middle blank -> {blank:?}, title {:?}", host.title());
+    check("middle-click target=_blank is queued", blank.iter().any(|u| u.path() == "/blank"));
+    check("  and the page stays", host.title().as_deref() == Some("links"));
+
+    let count = host.tab_count();
+    host.open_background_tab(url::Url::parse(&format!("{base}/bg")).unwrap());
+    settle(&mut host, 20);
+    check("background tab added", host.tab_count() == count + 1);
+    check("  active tab unchanged", host.active_index() == 0);
+    check(
+        "  and it loaded behind the page",
+        host.tab(count).and_then(|t| t.title.clone()).as_deref() == Some("bg"),
+    );
+
+    click(&mut host, MouseButton::Left, 60.0, 40.0);
+    settle(&mut host, 20);
+    let left = host.take_background_opens();
+    println!("left plain -> {left:?}, title {:?}", host.title());
+    check("left-click is not diverted", left.is_empty());
+    check("  and navigates", host.title().as_deref() == Some("plain"));
+
+    println!("\n{}", if ok { "OK" } else { "FAILED" });
+    std::process::exit(if ok { 0 } else { 1 });
+}
diff --git a/src/main.rs b/src/main.rs
index fc9890e..845d630 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -95,6 +95,9 @@ fn bar_h(favorites: bool) -> f32 {
 const BAR_RADIUS: f32 = 10.0;
 /// Seconds for the bar to unfold from the corner control (and back).
 const CHROME_ANIM_S: f32 = 0.18;
+/// How long the bar stays out after a tab opens in the background — long
+/// enough to see the new tab land in the strip — before folding itself.
+const CHROME_PEEK: std::time::Duration = std::time::Duration::from_millis(1500);
 
 /// One frame of the bar's unfold: `t` moves `dt` worth toward `target`
 /// (0 folded, 1 open), or straight onto it when the DE's animations switch
@@ -766,6 +769,14 @@ struct BrowserApp {
     /// `chrome_open` names.
     chrome_open: bool,
     chrome_t: f32,
+    /// Set while the bar is out on its own — unfolded to show a tab that
+    /// opened in the background — and when it folds again. Anything the
+    /// person does with the bar makes it theirs (`None`), and then it stays.
+    chrome_peek: Option<std::time::Instant>,
+    /// Where the pointer was when the peek began. Still there is not a
+    /// hover: a link middle-clicked near the top of the page sits under
+    /// the bar it unfolds, and must not hold it out.
+    peek_pointer: (f32, f32),
     /// Pointer over the corner control — its hover emphasis is a repaint.
     dot_hover: bool,
     loading: bool,
@@ -1757,10 +1768,7 @@ impl BrowserApp {
             return;
         };
         if new_tab {
-            self.host.open_tab(url);
-            self.url_focused = false;
-            self.sync_page_state();
-            self.persist_session();
+            self.open_background_tab(url);
         } else {
             self.host.load(url);
             self.loading = true;
@@ -1875,12 +1883,61 @@ impl BrowserApp {
 
     fn open_chrome(&mut self) {
         self.chrome_open = true;
+        self.chrome_peek = None;
+    }
+
+    /// Unfold the bar for a moment, to show a tab that just opened behind
+    /// the page. A bar the person already has open is left alone; a peek
+    /// already under way runs on from now.
+    fn peek_chrome(&mut self) {
+        if self.chrome_open && self.chrome_peek.is_none() {
+            return;
+        }
+        self.chrome_open = true;
+        self.peek_pointer = self.pointer;
+        self.arm_peek();
+    }
+
+    /// Set the peek's deadline, and a wake for it: an idle page produces
+    /// nothing that would turn the loop when it passes.
+    fn arm_peek(&mut self) {
+        self.chrome_peek = Some(std::time::Instant::now() + CHROME_PEEK);
+        let tx = self.sender.clone();
+        std::thread::spawn(move || {
+            std::thread::sleep(CHROME_PEEK);
+            let _ = tx.send(Message::Spin);
+        });
+    }
+
+    /// Fold a peek whose time is up. A pointer brought onto the bar holds
+    /// it out a while longer. Returns whether the bar folded.
+    fn settle_peek(&mut self) -> bool {
+        let Some(at) = self.chrome_peek else { return false };
+        if std::time::Instant::now() < at {
+            return false;
+        }
+        if self.pointer != self.peek_pointer && self.chrome_hit(self.pointer.0, self.pointer.1) {
+            self.arm_peek();
+            return false;
+        }
+        self.close_chrome();
+        true
+    }
+
+    /// Open `url` in a tab behind the page — a middle-click's tab — and
+    /// show the bar for a moment so the new tab is seen to arrive.
+    fn open_background_tab(&mut self, url: Url) {
+        self.host.open_background_tab(url);
+        self.sync_page_state();
+        self.persist_session();
+        self.peek_chrome();
     }
 
     /// Fold the bar back into the orb; drops URL-bar focus with it, since
     /// a field that is not on screen must not keep eating keystrokes.
     fn close_chrome(&mut self) {
         self.chrome_open = false;
+        self.chrome_peek = None;
         // The menu hangs off a bar that is going away.
         self.bm_menu = None;
         if self.url_focused {
@@ -2196,8 +2253,7 @@ impl BrowserApp {
             CtxAction::Paste => self.host.editing_action_cmd(EditingCommand::Paste),
             CtxAction::OpenInTab(uri) => {
                 if let Ok(url) = Url::parse(uri) {
-                    self.host.open_tab(url);
-                    self.sync_page_state();
+                    self.open_background_tab(url);
                 }
             }
             CtxAction::CopyText(text) => {
@@ -3114,6 +3170,8 @@ impl Application for BrowserApp {
             shift_held: false,
             chrome_open: false,
             chrome_t: 0.0,
+            chrome_peek: None,
+            peek_pointer: (0.0, 0.0),
             dot_hover: false,
             loading: true,
             title: None,
@@ -3232,6 +3290,14 @@ impl Application for BrowserApp {
                     self.opt_menu = None;
                     *needs_rebuild = true;
                 }
+                #[cfg(feature = "wpe")]
+                for url in self.host.take_background_opens() {
+                    self.open_background_tab(url);
+                    *needs_rebuild = true;
+                }
+                if self.settle_peek() {
+                    *needs_rebuild = true;
+                }
                 if self.host.take_download_started() {
                     self.open_internal_page("cce://downloads");
                 }
@@ -3746,6 +3812,8 @@ impl Application for BrowserApp {
                 return None;
             }
             *needs_rebuild = true;
+            // Using a bar that was only peeking makes it the person's.
+            self.chrome_peek = None;
             // The corner control toggles the bar, open or closed.
             if self.dot_hit(pos.x, pos.y) {
                 if button == MouseButton::Left {
@@ -3785,10 +3853,7 @@ impl Application for BrowserApp {
             if let Some(i) = self.fav_rects(&bar).iter().position(|r| r.contains(pos.x, pos.y)) {
                 let Ok(url) = Url::parse(&self.favs[i].url) else { return None };
                 if button == MouseButton::Middle {
-                    self.host.open_tab(url);
-                    self.url_focused = false;
-                    self.sync_page_state();
-                    self.persist_session();
+                    self.open_background_tab(url);
                 } else {
                     self.host.load(url);
                     self.loading = true;
@@ -3834,8 +3899,9 @@ impl Application for BrowserApp {
         }
 
         // Page area: a click folds the menu (and URL-bar focus with it),
-        // then goes to the page.
-        if self.chrome_open && pressed {
+        // then goes to the page. Not a peek, which folds on its own: a run
+        // of middle-clicked links keeps it out rather than flapping it.
+        if self.chrome_open && pressed && self.chrome_peek.is_none() {
             self.close_chrome();
             *needs_rebuild = true;
         }
diff --git a/src/webview.rs b/src/webview.rs
index 90969b0..24acad2 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -470,6 +470,13 @@ impl ServoHost {
         self.activate(self.tabs.len() - 1);
     }
 
+    /// Open a new tab behind the active one, which stays shown and focused.
+    pub fn open_background_tab(&mut self, url: Url) {
+        let back = self.active;
+        self.open_tab(url);
+        self.activate(back);
+    }
+
     /// 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 {
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 24cd839..9479282 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -743,6 +743,13 @@ impl WebKitHost {
                 on_show_option_menu as *const () as usize,
                 &self.prompts,
             );
+            // A middle-clicked link is a background tab, not a navigation.
+            connect_raw(
+                wv,
+                "decide-policy",
+                on_decide_policy as *const () as usize,
+                &self.prompts,
+            );
             let (lw, lh) = self.logical_size();
             wpe_view_resized(view, lw, lh);
             wpe_view_set_visible(view, 1);
@@ -770,6 +777,16 @@ impl WebKitHost {
     }
 
     pub fn open_tab(&mut self, url: Url) {
+        self.add_tab(url, true);
+    }
+
+    /// Open `url` in a new tab behind the active one: it loads, and shows
+    /// up in the strip, but the page on screen and its focus stay put.
+    pub fn open_background_tab(&mut self, url: Url) {
+        self.add_tab(url, false);
+    }
+
+    fn add_tab(&mut self, url: Url, show: bool) {
         let (webview, view, state) = match self.spare.take() {
             // Adopt the prewarmed webview; only the navigation is paid.
             Some((wv, view, state)) => {
@@ -782,6 +799,14 @@ impl WebKitHost {
             None => {
                 let state = Rc::new(TabState::default());
                 let (wv, view) = self.build_webview(&url, &state);
+                if !show {
+                    // Built mapped, like every webview; a background one
+                    // starts the way a switched-away tab is left.
+                    unsafe {
+                        wpe_view_unmap(view);
+                        wpe_view_set_visible(view, 0);
+                    }
+                }
                 (wv, view, state)
             }
         };
@@ -796,7 +821,9 @@ impl WebKitHost {
             loading: true,
             image: None,
         });
-        self.activate(self.tabs.len() - 1);
+        if show {
+            self.activate(self.tabs.len() - 1);
+        }
         // Replace the spare right away, but after the load started, so the
         // page fetch runs while this builds — measured, it does not show up
         // in the click-to-tab time.
@@ -2207,6 +2234,46 @@ unsafe extern "C" fn on_authenticate(
     1
 }
 
+/// A navigation is about to happen. A middle-click on a link — which WebKit
+/// reports as an ordinary navigation (or a new-window one, for a
+/// `target=_blank` link) carrying the button — is diverted to a background
+/// tab: the decision is ignored here and the URL queued for the chrome.
+/// Everything else gets WebKit's default.
+unsafe extern "C" fn on_decide_policy(
+    _wv: *mut WebKitWebView,
+    decision: *mut WebKitPolicyDecision,
+    kind: WebKitPolicyDecisionType::Type,
+    data: gpointer,
+) -> gboolean {
+    if kind != WebKitPolicyDecisionType::WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION
+        && kind != WebKitPolicyDecisionType::WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION
+    {
+        return 0;
+    }
+    let action = webkit_navigation_policy_decision_get_navigation_action(
+        decision as *mut WebKitNavigationPolicyDecision,
+    );
+    if action.is_null()
+        || webkit_navigation_action_get_mouse_button(action) != 2
+        || webkit_navigation_action_get_navigation_type(action)
+            != WebKitNavigationType::WEBKIT_NAVIGATION_TYPE_LINK_CLICKED
+    {
+        return 0;
+    }
+    let request = webkit_navigation_action_get_request(action);
+    let Some(url) = (!request.is_null())
+        .then(|| from_cstr(webkit_uri_request_get_uri(request)))
+        .flatten()
+        .and_then(|u| Url::parse(&u).ok())
+    else {
+        return 0;
+    };
+    webkit_policy_decision_ignore(decision);
+    let prompts = &*(data as *const RefCell<Prompts>);
+    prompts.borrow_mut().background_opens.push(url);
+    1
+}
+
 /// The page asked for a context menu. Stash what the hit test says was under
 /// the pointer and claim presentation; the chrome draws the menu at the
 /// pointer position it already tracks (the hit test carries no coordinates).