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

commit73b30ed386372e77d215bc20577b41ab0058ecc9
parent5f83f41718
authorLucas Galante <[email protected]>
date2026-08-28 10:53
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};