web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
feat(wpe): input — pointer, keyboard and wheel into the page
Verified rather than assumed. examples/wpe_input loads a page that appends
every event it receives to document.title and reads it back through the
page-state sync that already worked:
"move down0 up0 click@300,220 key:a wheel:1"
Pointer move, button down and up, click at the exact coordinates sent,
keydown, and wheel all arrive.
The mapping lives in src/wpe/input.rs and WebKitHost takes cce-ui's own
MouseButton and KeyEvent. That is a deliberate change from the Servo
backend, where main.rs carried dom_key and dom_button and the host spoke
engine types: keeping engine vocabulary out of the chrome means the swap
deletes those helpers rather than rewriting them.
Keyboard is the part that would have failed silently. wpe_event_keyboard_new
wants an X11 keysym, not a character — Latin-1 is identity, above that is
the codepoint in the 0x01000000 plane, and named keys are fixed XK_ values.
A wrong keysym produces no error, just a page that ignores you.
The wheel sign was wrong and the test is what caught it. cce-ui uses
winit's convention, positive is scroll up, and the first cut negated on the
way out by analogy with Servo, which inverts internally. WPE already
inverts on the way to the DOM, so that double-inverted and the page
reported deltaY of the wrong sign — scrolling backwards. Passed through
unchanged now, and the comment says it was measured.
focus() is included because its absence looks exactly like a broken key
mapping: no focused frame, keyboard input silently dropped.
Co-Authored-By: Claude Opus 5 <[email protected]>
examples/wpe_input.rs | 78 +++++++++++++++++++++++++++++
src/wpe/host.rs | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++
src/wpe/input.rs | 118 ++++++++++++++++++++++++++++++++++++++++++++
src/wpe/mod.rs | 1 +
4 files changed, 331 insertions(+)
diff --git a/examples/wpe_input.rs b/examples/wpe_input.rs
new file mode 100644
index 0000000..3cc83c9
--- /dev/null
+++ b/examples/wpe_input.rs
@@ -0,0 +1,78 @@
+//! Proves input actually reaches the page.
+//!
+//! Loads a page that appends every event it receives to `document.title`,
+//! then drives `WebKitHost`'s input methods and reads the title back — which
+//! works because page-state sync is already wired. No JS-evaluation API needed.
+//!
+//! `cargo run --release -p cce-browser --features wpe --example wpe_input -- <url>`
+
+#[cfg(not(feature = "wpe"))]
+fn main() {
+ eprintln!("build with --features wpe");
+}
+
+#[cfg(feature = "wpe")]
+#[path = "../src/wpe/mod.rs"]
+mod wpe;
+
+#[cfg(feature = "wpe")]
+fn main() {
+ use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton};
+
+ let url = std::env::args()
+ .nth(1)
+ .unwrap_or_else(|| "http://127.0.0.1:8750/input.html".into());
+ let mut host = wpe::WebKitHost::new(url::Url::parse(&url).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));
+ }
+ };
+
+ settle(&mut host, 40);
+ println!("loaded: title={:?}", host.title());
+ host.focus(true);
+
+ 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);
+ settle(&mut host, 8);
+ println!(" title={:?}", host.title());
+
+ println!("-- key 'a' --");
+ let key = |c: &str, pressed: bool| KeyEvent {
+ state: if pressed { ElementState::Pressed } else { ElementState::Released },
+ logical_key: Key::Character(c.into()),
+ text: Some(c.into()),
+ repeat: false,
+ ctrl: false,
+ shift: false,
+ alt: false,
+ };
+ host.key(&key("a", true));
+ host.key(&key("a", false));
+ settle(&mut host, 8);
+ println!(" title={:?}", host.title());
+
+ println!("-- wheel down --");
+ host.wheel(0.0, -120.0, 300.0, 220.0);
+ settle(&mut host, 8);
+ let title = host.title().unwrap_or_default();
+ println!(" title={title:?}");
+
+ println!("\n=== RESULT ===");
+ for (label, needle) in [
+ ("pointer move", "move"),
+ ("button down", "down0"),
+ ("button up", "up0"),
+ ("click", "click@300,220"),
+ ("keydown 'a'", "key:a"),
+ ("wheel", "wheel:"),
+ ] {
+ println!(" {:<14} {}", label, if title.contains(needle) { "OK" } else { "MISSING" });
+ }
+}
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index b90813d..f3bd99d 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -19,7 +19,10 @@ use std::rc::Rc;
use url::Url;
+use cce_ui::widget::{KeyEvent, MouseButton};
+
use super::ffi::*;
+use super::input;
use super::subclass::{types, FRAME_SINK};
/// One tab: its webview plus the app-visible page state and the last frame
@@ -239,6 +242,137 @@ impl WebKitHost {
unsafe { webkit_web_view_can_go_forward(self.active_tab().webview) != 0 }
}
+ // ---- input ----
+ //
+ // Coordinates are device pixels relative to the view origin, matching
+ // `ServoHost`'s convention so `main.rs` scales them the same way. Events
+ // are refcounted; `wpe_view_event` takes its own reference, so each one is
+ // unreffed here after delivery.
+
+ pub fn mouse_move(&self, x_px: f32, y_px: f32) {
+ unsafe {
+ let view = self.active_tab().view;
+ let e = wpe_event_pointer_move_new(
+ WPEEventType::WPE_EVENT_POINTER_MOVE,
+ view,
+ WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
+ input::now_ms(),
+ 0,
+ x_px as f64,
+ y_px as f64,
+ 0.0,
+ 0.0,
+ );
+ self.send(view, e);
+ }
+ }
+
+ pub fn mouse_button(&self, button: MouseButton, pressed: bool, x_px: f32, y_px: f32) {
+ let Some(n) = input::button_number(button) else {
+ return;
+ };
+ unsafe {
+ let view = self.active_tab().view;
+ let time = input::now_ms();
+ // WPE tracks double/triple clicks for us; a frozen clock here
+ // would make every click read as a repeat.
+ let press_count = if pressed {
+ wpe_view_compute_press_count(view, x_px as f64, y_px as f64, n, time)
+ } else {
+ 0
+ };
+ let e = wpe_event_pointer_button_new(
+ if pressed {
+ WPEEventType::WPE_EVENT_POINTER_DOWN
+ } else {
+ WPEEventType::WPE_EVENT_POINTER_UP
+ },
+ view,
+ WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
+ time,
+ 0,
+ n,
+ x_px as f64,
+ y_px as f64,
+ press_count,
+ );
+ self.send(view, e);
+ }
+ }
+
+ /// Wheel deltas in device pixels, in cce-ui's winit convention (positive
+ /// = scroll up), passed through **unchanged**.
+ ///
+ /// Measured, not assumed: WPE already inverts on the way to the DOM, so a
+ /// negation here double-inverts and the page scrolls backwards. An
+ /// earlier cut negated these and `examples/wpe_input` caught it — the
+ /// page reported `deltaY` of the wrong sign.
+ pub fn wheel(&self, dx_px: f64, dy_px: f64, x_px: f32, y_px: f32) {
+ unsafe {
+ let view = self.active_tab().view;
+ let e = wpe_event_scroll_new(
+ view,
+ WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
+ input::now_ms(),
+ 0,
+ dx_px,
+ dy_px,
+ 1, // precise deltas: these are pixels, not notches
+ 0, // not a scroll-stop event
+ x_px as f64,
+ y_px as f64,
+ );
+ self.send(view, e);
+ }
+ }
+
+ /// 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) {
+ let Some(keyval) = input::keyval(&event.logical_key) else {
+ return;
+ };
+ let pressed = input::is_pressed(event);
+ unsafe {
+ let view = self.active_tab().view;
+ let e = wpe_event_keyboard_new(
+ if pressed {
+ WPEEventType::WPE_EVENT_KEYBOARD_KEY_DOWN
+ } else {
+ WPEEventType::WPE_EVENT_KEYBOARD_KEY_UP
+ },
+ view,
+ WPEInputSource::WPE_INPUT_SOURCE_KEYBOARD,
+ input::now_ms(),
+ input::modifiers(event.ctrl, event.shift, event.alt),
+ 0, // hardware keycode: unknown to us, and WebKit works off keyval
+ keyval,
+ );
+ self.send(view, e);
+ }
+ }
+
+ /// Page focus. Without this the page has no focused frame and keyboard
+ /// input is dropped, which looks exactly like a broken key mapping.
+ pub fn focus(&self, focused: bool) {
+ unsafe {
+ let view = self.active_tab().view;
+ if focused {
+ wpe_view_focus_in(view)
+ } else {
+ wpe_view_focus_out(view)
+ }
+ }
+ }
+
+ unsafe fn send(&self, view: *mut WPEView, event: *mut WPEEvent) {
+ if event.is_null() {
+ return;
+ }
+ wpe_view_event(view, event);
+ wpe_event_unref(event);
+ }
+
pub fn resize(&mut self, width_px: u32, height_px: u32, scale: f32) {
self.size_px = (width_px.max(1), height_px.max(1));
self.scale = scale;
diff --git a/src/wpe/input.rs b/src/wpe/input.rs
new file mode 100644
index 0000000..506652e
--- /dev/null
+++ b/src/wpe/input.rs
@@ -0,0 +1,118 @@
+//! Translating cce-ui input into `WPEEvent`s.
+//!
+//! Unlike the Servo backend — where `main.rs` carried `dom_key`/`dom_button`
+//! helpers and the host took engine types — the mapping lives *here* and
+//! [`super::WebKitHost`] takes cce-ui's own `MouseButton` / `KeyEvent`. That
+//! keeps engine vocabulary out of the chrome, so switching backends deletes
+//! those helpers from `main.rs` rather than rewriting them.
+//!
+//! **Keyboard is the fiddly part.** `wpe_event_keyboard_new` wants an X11
+//! *keysym* (`keyval`), not a character. Latin-1 codepoints are their own
+//! keysym; anything above maps to `codepoint + 0x0100_0000`; named keys have
+//! fixed `XK_*` values. Getting this wrong is silent — the page just receives
+//! nothing useful.
+
+use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, NamedKey};
+
+use super::ffi::*;
+
+/// X11 keysyms for the named keys cce-ui reports (`/usr/include/X11/keysymdef.h`).
+mod keysym {
+ pub const BACKSPACE: u32 = 0xff08;
+ pub const TAB: u32 = 0xff09;
+ pub const RETURN: u32 = 0xff0d;
+ pub const ESCAPE: u32 = 0xff1b;
+ pub const SPACE: u32 = 0x0020;
+ pub const HOME: u32 = 0xff50;
+ pub const LEFT: u32 = 0xff51;
+ pub const UP: u32 = 0xff52;
+ pub const RIGHT: u32 = 0xff53;
+ pub const DOWN: u32 = 0xff54;
+ pub const PAGE_UP: u32 = 0xff55;
+ pub const PAGE_DOWN: u32 = 0xff56;
+ pub const END: u32 = 0xff57;
+ pub const DELETE: u32 = 0xffff;
+ pub const F5: u32 = 0xffc2;
+ pub const SHIFT_L: u32 = 0xffe1;
+ pub const CONTROL_L: u32 = 0xffe3;
+ pub const ALT_L: u32 = 0xffe9;
+ pub const SUPER_L: u32 = 0xffeb;
+}
+
+/// A Unicode scalar as an X11 keysym: Latin-1 is identity, the rest is the
+/// codepoint in the 0x01000000 plane.
+fn unicode_keysym(c: char) -> u32 {
+ match c as u32 {
+ cp @ 0x20..=0xff => cp,
+ cp => cp + 0x0100_0000,
+ }
+}
+
+/// cce-ui key -> X11 keysym. `None` for keys with no sensible mapping.
+pub(super) fn keyval(key: &Key) -> Option<u32> {
+ Some(match key {
+ Key::Character(s) => unicode_keysym(s.chars().next()?),
+ Key::Named(n) => match n {
+ NamedKey::Backspace => keysym::BACKSPACE,
+ NamedKey::Tab => keysym::TAB,
+ NamedKey::Enter => keysym::RETURN,
+ NamedKey::Escape => keysym::ESCAPE,
+ NamedKey::Space => keysym::SPACE,
+ NamedKey::ArrowDown => keysym::DOWN,
+ NamedKey::ArrowLeft => keysym::LEFT,
+ NamedKey::ArrowRight => keysym::RIGHT,
+ NamedKey::ArrowUp => keysym::UP,
+ NamedKey::End => keysym::END,
+ NamedKey::Home => keysym::HOME,
+ NamedKey::PageDown => keysym::PAGE_DOWN,
+ NamedKey::PageUp => keysym::PAGE_UP,
+ NamedKey::Delete => keysym::DELETE,
+ NamedKey::Control => keysym::CONTROL_L,
+ NamedKey::Shift => keysym::SHIFT_L,
+ NamedKey::Alt => keysym::ALT_L,
+ NamedKey::Super => keysym::SUPER_L,
+ NamedKey::F5 => keysym::F5,
+ },
+ })
+}
+
+/// X11 button numbering, which is what WPE expects.
+pub(super) fn button_number(b: MouseButton) -> Option<u32> {
+ Some(match b {
+ MouseButton::Left => 1,
+ MouseButton::Middle => 2,
+ MouseButton::Right => 3,
+ // Back/Forward are chrome navigation in `main.rs`, deliberately not
+ // forwarded to the page.
+ _ => return None,
+ })
+}
+
+pub(super) fn modifiers(ctrl: bool, shift: bool, alt: bool) -> WPEModifiers::Type {
+ let mut m: WPEModifiers::Type = 0;
+ if ctrl {
+ m |= WPEModifiers::WPE_MODIFIER_KEYBOARD_CONTROL;
+ }
+ if shift {
+ m |= WPEModifiers::WPE_MODIFIER_KEYBOARD_SHIFT;
+ }
+ if alt {
+ m |= WPEModifiers::WPE_MODIFIER_KEYBOARD_ALT;
+ }
+ m
+}
+
+pub(super) fn is_pressed(e: &KeyEvent) -> bool {
+ e.state == ElementState::Pressed
+}
+
+/// WPE stamps events with a millisecond clock. It only has to be monotonic
+/// and consistent — `wpe_view_compute_press_count` uses it for double-click
+/// detection, so a frozen value would turn every click into a triple-click.
+pub(super) fn now_ms() -> u32 {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_millis() as u32)
+ .unwrap_or(0)
+}
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
index 51f9d0a..de884e9 100644
--- a/src/wpe/mod.rs
+++ b/src/wpe/mod.rs
@@ -10,6 +10,7 @@ pub mod ffi {
}
mod subclass;
+mod input;
mod host;
// Not consumed yet — main.rs still drives ServoHost.