web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
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).