web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
feat(wpe): main.rs runs on either engine, chosen at compile time
cce-browser now builds and runs as a WebKit browser under --features wpe.
Shadow-verified end to end: the app comes up as a real Wayland window,
renders example.com under its own chrome, propagates the title to the
toplevel, and a pointer-injected click on a link navigates and updates the
title to "Example Domains". That last part is the event loop proving
itself — the title could not update unless register_sources were firing
Spin and driving pump.
The swap is a type alias because both hosts were made to present one
surface first. What made that possible is moving the engine vocabulary out
of the chrome: dom_key and dom_button are gone from main.rs, and
ServoHost grew key_ui / mouse_button_ui / editing_action_cmd /
set_color_scheme_dark taking cce-ui's own types, matching what WebKitHost
already took. EditingCommand is now declared in main.rs and each backend
maps it to its own vocabulary, so the chrome names neither engine.
register_sources is where the two backends genuinely differ. Servo pushed
Spin into calloop from its own threads; WPE runs a GLib main context, so
the epoll fd carrying its pollfd set is registered level-triggered
alongside a timer re-armed from poll_timeout, both firing the same Spin.
Downstream nothing changed — update still calls pump.
settings::ColorScheme::is_dark replaces the servo::Theme conversion, and
keeps the reason force-dark reports *light* with the answer rather than
in main.rs: the filter inverts unconditionally, so a site with a real dark
theme would be handed an already-dark page and inverted back to light.
The examples grew required-features so a default cargo test no longer
tries to build them without the bindings. Default build and all four tests
still pass; the shipping browser is still Servo and is untouched by any of
this.
Incidentally the WPE binary is 25 MB against Servo's 177 MB, WebKit being
a shared library where Servo is statically linked.
Co-Authored-By: Claude Opus 5 <[email protected]>
Cargo.toml | 27 +++++++++++++
examples/wpe_input.rs | 8 ++--
src/main.rs | 106 ++++++++++++++++++++++++++++++++++++++++----------
src/settings.rs | 9 +++++
src/webview.rs | 78 +++++++++++++++++++++++++++++++++++++
src/wpe/host.rs | 41 +++++++++----------
src/wpe/mod.rs | 2 +-
7 files changed, 224 insertions(+), 47 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 8e9f67d..60e976b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -31,3 +31,30 @@ rustix = { version = "0.38", features = ["event"], optional = true }
[build-dependencies]
bindgen = "0.72"
pkg-config = "0.3"
+
+# The WPE examples need the generated bindings, so they only build with
+# the feature — otherwise `cargo test` would try them on a default build.
+
+[[example]]
+name = "wpe_spike"
+required-features = ["wpe"]
+
+[[example]]
+name = "wpe_host"
+required-features = ["wpe"]
+
+[[example]]
+name = "wpe_input"
+required-features = ["wpe"]
+
+[[example]]
+name = "wpe_loop"
+required-features = ["wpe"]
+
+[[example]]
+name = "wpe_tabs"
+required-features = ["wpe"]
+
+[[example]]
+name = "wpe_dark"
+required-features = ["wpe"]
diff --git a/examples/wpe_input.rs b/examples/wpe_input.rs
index a1f8b93..d1adec4 100644
--- a/examples/wpe_input.rs
+++ b/examples/wpe_input.rs
@@ -44,8 +44,8 @@ fn main() {
println!("-- pointer move + left click at (300,220) --");
host.mouse_move(300.0, 220.0);
settle(&mut host, 4);
- host.mouse_button(MouseButton::Left, true, 300.0, 220.0);
- host.mouse_button(MouseButton::Left, false, 300.0, 220.0);
+ host.mouse_button_ui(MouseButton::Left, true, 300.0, 220.0);
+ host.mouse_button_ui(MouseButton::Left, false, 300.0, 220.0);
settle(&mut host, 8);
println!(" title={:?}", host.title());
@@ -59,8 +59,8 @@ fn main() {
shift: false,
alt: false,
};
- host.key(&key("a", true));
- host.key(&key("a", false));
+ host.key_ui(&key("a", true));
+ host.key_ui(&key("a", false));
settle(&mut host, 8);
println!(" title={:?}", host.title());
diff --git a/src/main.rs b/src/main.rs
index 3dbd510..5cf9d50 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -24,7 +24,20 @@ 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;
+#[cfg(not(feature = "wpe"))]
+use webview::ServoHost as Host;
+#[cfg(feature = "wpe")]
+use wpe::WebKitHost as Host;
+
+/// Clipboard action, named by neither engine. Each backend maps it to its
+/// own vocabulary — Servo needs an `EditingActionEvent`, WebKit a named
+/// editing command — so the chrome never learns either.
+#[derive(Debug, Clone, Copy)]
+pub enum EditingCommand {
+ Copy,
+ Cut,
+ Paste,
+}
const BAR_MARGIN: f32 = 10.0;
/// Two rows: tab strip on top, nav controls + URL field below.
@@ -75,7 +88,7 @@ pub enum Message {
}
struct BrowserApp {
- host: ServoHost,
+ host: Host,
/// Loaded from the app config; re-read when the window regains focus.
settings: settings::Settings,
win: (f32, f32),
@@ -93,6 +106,10 @@ struct BrowserApp {
/// Page title; drives the toplevel title (the engine re-applies
/// `settings().title` whenever it changes).
title: Option<String>,
+ /// Kept so the WPE backend's calloop sources can fire `Spin`; Servo
+ /// wakes the loop itself through its `EventLoopWaker`.
+ #[cfg(feature = "wpe")]
+ sender: calloop::channel::Sender<Message>,
/// App-side bundled-fonts `FontSystem` (the same set the toolkit renders
/// with) for URL-bar caret/click metrics via `shaped_cluster_offsets` —
/// `measure_text_width`'s inked-extent numbers drift off the drawn glyphs.
@@ -351,7 +368,7 @@ impl BrowserApp {
}
downloads::set_download_dir(new.download_dir.clone());
self.host.set_history_enabled(new.history);
- self.host.set_color_scheme(new.color_scheme.into());
+ self.host.set_color_scheme_dark(new.color_scheme.is_dark());
self.host.set_force_dark(new.color_scheme.forces_dark());
self.settings = new;
true
@@ -632,9 +649,17 @@ impl Application for BrowserApp {
.unwrap_or_else(|| Url::parse(settings::DEFAULT_HOMEPAGE).expect("home url"));
let url_input = url.to_string();
let cursor = url_input.len();
- let mut host = ServoHost::new(sender, url, (1200, 800), settings.color_scheme.forces_dark());
+ #[cfg(not(feature = "wpe"))]
+ let mut host = Host::new(sender, url, (1200, 800), settings.color_scheme.forces_dark());
+ #[cfg(feature = "wpe")]
+ let mut host = {
+ let _ = &sender; // WPE wakes through register_sources, not a waker
+ Host::new(url, (1200, 800))
+ };
host.set_history_enabled(settings.history);
- host.set_color_scheme(settings.color_scheme.into());
+ host.set_color_scheme_dark(settings.color_scheme.is_dark());
+ #[cfg(feature = "wpe")]
+ host.set_force_dark(settings.color_scheme.forces_dark());
Self {
host,
settings,
@@ -647,10 +672,58 @@ impl Application for BrowserApp {
selection: None,
loading: true,
title: None,
+ #[cfg(feature = "wpe")]
+ sender,
font_system: cce_ui::create_font_system(),
}
}
+ /// Wake on GLib activity rather than polling for it.
+ ///
+ /// Servo pushed `Message::Spin` into calloop from its own threads; WPE
+ /// runs a GLib main context, so we register the epoll fd carrying its
+ /// pollfd set plus a timer for the timeout GLib asks for. Both just fire
+ /// `Spin`, which lands in `update` and calls `pump` — the same path the
+ /// Servo waker used, so nothing downstream changes.
+ #[cfg(feature = "wpe")]
+ fn register_sources(&mut self, handle: &calloop::LoopHandle<'_, EngineState<Self>>) {
+ use calloop::{generic::Generic, Interest, Mode, PostAction};
+
+ if let Some(fd) = self.host.poll_fd_owned() {
+ let tx = self.sender.clone();
+ // Level-triggered: `pump` drains the epoll, so an un-consumed
+ // socket re-arms rather than being missed.
+ let source = Generic::new(fd, Interest::READ, Mode::Level);
+ if let Err(e) = handle.insert_source(source, move |_, _, _| {
+ let _ = tx.send(Message::Spin);
+ Ok(PostAction::Continue)
+ }) {
+ log::warn!("could not watch the GLib fd ({e}); falling back to the timer alone");
+ }
+ }
+
+ // GLib also asks to be woken on its own schedule (timeouts, animation
+ // frames), which no fd reports. Re-armed from `poll_timeout` each
+ // fire, so an idle page settles to long sleeps instead of a fixed tick.
+ let tx = self.sender.clone();
+ let timer = calloop::timer::Timer::from_duration(std::time::Duration::from_millis(16));
+ if let Err(e) = handle.insert_source(timer, move |_, _, state| {
+ let _ = tx.send(Message::Spin);
+ let next = state
+ .inner
+ .as_ref()
+ .and_then(|app| app.host.poll_timeout())
+ .unwrap_or(std::time::Duration::from_millis(100))
+ .clamp(
+ std::time::Duration::from_millis(4),
+ std::time::Duration::from_millis(250),
+ );
+ calloop::timer::TimeoutAction::ToDuration(next)
+ }) {
+ log::warn!("could not arm the GLib timer ({e})");
+ }
+ }
+
fn settings(&self) -> WindowSettings {
WindowSettings {
title: self.title.clone().unwrap_or_else(|| "Browser".to_string()),
@@ -779,10 +852,9 @@ impl Application for BrowserApp {
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 * s);
- }
+ let s = self.scale as f32;
+ self.host
+ .mouse_button_ui(button, pressed, pos.x * s, pos.y * s);
}
}
None
@@ -870,10 +942,10 @@ impl Application for BrowserApp {
// Page clipboard: Servo needs the chord as an
// editing action, not as the raw keystroke.
"c" | "x" | "v" => {
- self.host.editing_action(match c.as_str() {
- "c" => servo::EditingActionEvent::Copy,
- "x" => servo::EditingActionEvent::Cut,
- _ => servo::EditingActionEvent::Paste,
+ self.host.editing_action_cmd(match c.as_str() {
+ "c" => EditingCommand::Copy,
+ "x" => EditingCommand::Cut,
+ _ => EditingCommand::Paste,
});
return None;
}
@@ -897,13 +969,7 @@ impl Application for BrowserApp {
}
}
- if let Some(k) = dom_key(&event.logical_key) {
- let mut modifiers = servo::Modifiers::empty();
- modifiers.set(servo::Modifiers::CONTROL, event.ctrl);
- modifiers.set(servo::Modifiers::SHIFT, event.shift);
- modifiers.set(servo::Modifiers::ALT, event.alt);
- self.host.key(k, event.state == ElementState::Pressed, modifiers);
- }
+ self.host.key_ui(event);
None
}
diff --git a/src/settings.rs b/src/settings.rs
index d6998a2..c212590 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -50,6 +50,15 @@ impl ColorScheme {
}
}
+ /// What to report for `prefers-color-scheme`, backend-neutrally.
+ ///
+ /// Force-dark reports **light** on purpose: the filter inverts
+ /// unconditionally, so a site with a real dark theme would be handed an
+ /// already-dark page and inverted back into a light one.
+ pub fn is_dark(self) -> bool {
+ matches!(self, Self::Dark)
+ }
+
/// Whether the inverting user stylesheet is installed.
pub fn forces_dark(self) -> bool {
matches!(self, Self::ForceDark)
diff --git a/src/webview.rs b/src/webview.rs
index e258d9b..2a359e4 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -716,6 +716,84 @@ impl ServoHost {
)));
}
+ /// Clipboard action on the page, in backend-neutral terms.
+ pub fn editing_action_cmd(&self, command: crate::EditingCommand) {
+ self.editing_action(match command {
+ crate::EditingCommand::Copy => EditingActionEvent::Copy,
+ crate::EditingCommand::Cut => EditingActionEvent::Cut,
+ crate::EditingCommand::Paste => EditingActionEvent::Paste,
+ });
+ }
+
+ /// Pointer button in cce-ui's vocabulary. The Servo mapping lives here
+ /// rather than in `main.rs` so the chrome names no engine's types — the
+ /// WPE backend takes the same arguments.
+ pub fn mouse_button_ui(
+ &self,
+ button: cce_ui::widget::MouseButton,
+ pressed: bool,
+ x_px: f32,
+ y_px: f32,
+ ) {
+ use cce_ui::widget::MouseButton as Ui;
+ let Some(b) = (match button {
+ Ui::Left => Some(DomMouseButton::Left),
+ Ui::Right => Some(DomMouseButton::Right),
+ Ui::Middle => Some(DomMouseButton::Middle),
+ _ => None,
+ }) else {
+ return;
+ };
+ self.mouse_button(b, pressed, x_px, y_px);
+ }
+
+ /// A cce-ui key event, translated and forwarded. Same signature as the
+ /// WPE backend's `key`.
+ pub fn key_ui(&self, event: &cce_ui::widget::KeyEvent) {
+ use cce_ui::widget::{ElementState as St, Key as UiKey, NamedKey as Nk};
+ let Some(k) = (match &event.logical_key {
+ UiKey::Character(s) => Some(DomKey::Character(s.clone())),
+ UiKey::Named(Nk::Space) => Some(DomKey::Character(" ".into())),
+ UiKey::Named(n) => Some(DomKey::Named(match n {
+ Nk::Backspace => servo::NamedKey::Backspace,
+ Nk::Tab => servo::NamedKey::Tab,
+ Nk::Enter => servo::NamedKey::Enter,
+ Nk::Escape => servo::NamedKey::Escape,
+ Nk::ArrowDown => servo::NamedKey::ArrowDown,
+ Nk::ArrowLeft => servo::NamedKey::ArrowLeft,
+ Nk::ArrowRight => servo::NamedKey::ArrowRight,
+ Nk::ArrowUp => servo::NamedKey::ArrowUp,
+ Nk::End => servo::NamedKey::End,
+ Nk::Home => servo::NamedKey::Home,
+ Nk::PageDown => servo::NamedKey::PageDown,
+ Nk::PageUp => servo::NamedKey::PageUp,
+ Nk::Delete => servo::NamedKey::Delete,
+ Nk::Control => servo::NamedKey::Control,
+ Nk::Shift => servo::NamedKey::Shift,
+ Nk::Alt => servo::NamedKey::Alt,
+ Nk::Super => servo::NamedKey::Meta,
+ Nk::F5 => servo::NamedKey::F5,
+ Nk::Space => unreachable!("handled above"),
+ })),
+ }) else {
+ return;
+ };
+ let mut modifiers = Modifiers::empty();
+ modifiers.set(Modifiers::CONTROL, event.ctrl);
+ modifiers.set(Modifiers::SHIFT, event.shift);
+ modifiers.set(Modifiers::ALT, event.alt);
+ self.key(k, event.state == St::Pressed, modifiers);
+ }
+
+ /// Colour scheme in backend-neutral terms: dark or not.
+ pub fn set_color_scheme_dark(&mut self, dark: bool) {
+ self.set_color_scheme(if dark { Theme::Dark } else { Theme::Light });
+ }
+
+ /// Page focus. A no-op for Servo, which tracks focus itself; present so
+ /// both backends accept the same call.
+ pub fn focus(&self, _focused: bool) {}
+
/// Forward a key to the page, modifiers included.
///
/// `from_state_and_key` defaults the modifiers to empty, which delivers
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 26abbb4..1b52bed 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -304,6 +304,16 @@ impl WebKitHost {
self.poll.as_ref().map(|p| p.fd())
}
+ /// An owned duplicate of [`Self::poll_fd`], for handing to calloop.
+ ///
+ /// calloop wants to own what it polls, and the borrow above is tied to
+ /// `&self`. A dup refers to the same epoll instance, so registrations
+ /// made through the original are still what this observes.
+ pub fn poll_fd_owned(&self) -> Option<std::os::fd::OwnedFd> {
+ let fd = self.poll.as_ref()?.fd();
+ rustix::io::dup(fd).ok()
+ }
+
/// How long calloop may sleep before pumping anyway, per GLib.
pub fn poll_timeout(&self) -> Option<std::time::Duration> {
self.poll
@@ -448,7 +458,7 @@ impl WebKitHost {
}
/// What pages see for `prefers-color-scheme`, via WPE's own setting.
- pub fn set_color_scheme(&self, dark: bool) {
+ pub fn set_color_scheme_dark(&self, dark: bool) {
unsafe {
let settings = wpe_display_get_settings(self.display);
let key = cstr("/wpe-platform/dark-mode");
@@ -487,9 +497,13 @@ impl WebKitHost {
/// Clipboard on the page. WebKit takes these as named editing commands,
/// so unlike the Servo backend there is no separate clipboard delegate to
/// implement — it goes through the platform clipboard itself.
- pub fn editing_action(&self, command: EditingCommand) {
+ pub fn editing_action_cmd(&self, command: crate::EditingCommand) {
unsafe {
- let c = cstr(command.as_str());
+ let c = cstr(match command {
+ crate::EditingCommand::Copy => "Copy",
+ crate::EditingCommand::Cut => "Cut",
+ crate::EditingCommand::Paste => "Paste",
+ });
webkit_web_view_execute_editing_command(self.active_tab().webview, c.as_ptr());
}
}
@@ -519,7 +533,7 @@ impl WebKitHost {
}
}
- pub fn mouse_button(&self, button: MouseButton, pressed: bool, x_px: f32, y_px: f32) {
+ pub fn mouse_button_ui(&self, button: MouseButton, pressed: bool, x_px: f32, y_px: f32) {
let Some(n) = input::button_number(button) else {
return;
};
@@ -580,7 +594,7 @@ impl WebKitHost {
/// Takes cce-ui's `KeyEvent` directly — the keysym mapping lives in
/// `input`, so the chrome never learns engine vocabulary.
- pub fn key(&self, event: &KeyEvent) {
+ pub fn key_ui(&self, event: &KeyEvent) {
let Some(keyval) = input::keyval(&event.logical_key) else {
return;
};
@@ -675,23 +689,6 @@ unsafe fn from_cstr(p: *const c_char) -> Option<String> {
.filter(|s| !s.is_empty())
}
-/// Backend-neutral clipboard action, so `main.rs` names neither engine's.
-#[derive(Debug, Clone, Copy)]
-pub enum EditingCommand {
- Copy,
- Cut,
- Paste,
-}
-
-impl EditingCommand {
- fn as_str(self) -> &'static str {
- match self {
- Self::Copy => "Copy",
- Self::Cut => "Cut",
- Self::Paste => "Paste",
- }
- }
-}
/// Same inverting stylesheet the Servo backend uses, and for the same reason:
/// it is the only thing that darkens a page shipping a hardcoded white with no
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
index 052ea04..3c11e30 100644
--- a/src/wpe/mod.rs
+++ b/src/wpe/mod.rs
@@ -16,4 +16,4 @@ mod host;
// Not consumed yet — main.rs still drives ServoHost.
#[allow(unused_imports)]
-pub use host::{EditingCommand, Tab, WebKitHost};
+pub use host::{Tab, WebKitHost};