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

commitc7dfbebee26be84ce3f2114587926819ac70251b
parent8b8dae78d8
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 10:24
fix(wpe): give pages window focus, so the text caret shows

WebKit paints a caret only in a page that is both focused (the view) and
active (the toplevel's ACTIVE state). Keystrokes reach a field with
neither, so the only symptom was typing into a field with no caret.

Two causes:

- Nothing in the app called WebKitHost::focus, and it never set the
  toplevel's ACTIVE state anyway. handle_focus_change now calls it; it
  sets both, and activate() moves view focus to the tab being shown.
- The toplevel was created with max_views = 1 (the spike's value), and a
  full toplevel refuses wpe_view_set_toplevel silently. Only the first
  tab ever attached, so every later tab also lacked the window's scale
  and rendered at half resolution on a 2x output. 0 is unlimited.

examples/wpe_focus.rs checks document.hasFocus() across window focus
changes, a new tab and a tab switch; it failed before and passes now.

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

 WPE-PORT.md           |  1 +
 examples/wpe_focus.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs           |  2 ++
 src/webview.rs        |  2 +-
 src/wpe/host.rs       | 47 +++++++++++++++++++++++++++------
 5 files changed, 115 insertions(+), 9 deletions(-)

diff --git a/WPE-PORT.md b/WPE-PORT.md
index 87ae6ce..1569543 100644
--- a/WPE-PORT.md
+++ b/WPE-PORT.md
@@ -333,6 +333,7 @@ The examples are the test suite; all need `--features wpe`.
 | `wpe_host` | boot, frames, page state, navigation, history |
 | `wpe_input` | pointer / keyboard / wheel reaching the page, read back via `document.title` |
 | `wpe_tabs` | several views on one display, and a **backgrounded** tab still updating |
+| `wpe_focus` | window focus reaching every tab's page (`document.hasFocus()`), the condition WebKit paints the text caret on |
 | `wpe_loop` | blocking on GLib's fds vs polling, with the wakeup counts |
 | `wpe_dark` | force-dark, asserted on rendered pixels rather than on the call |
 
diff --git a/examples/wpe_focus.rs b/examples/wpe_focus.rs
new file mode 100644
index 0000000..2e97fe7
--- /dev/null
+++ b/examples/wpe_focus.rs
@@ -0,0 +1,72 @@
+//! Proves the page believes it is focused — the condition WebKit gates the
+//! text caret on.
+//!
+//! A caret is painted only in a frame that is both *focused* (the view's
+//! focus) and *active* (the toplevel's `ACTIVE` state). Without either, a
+//! field takes typing perfectly well and shows no caret, which is how it
+//! shipped. `document.hasFocus()` reads the same two conditions, so the page
+//! reports it into `document.title` and this reads it back, across the window
+//! losing and regaining focus and across a tab switch.
+//!
+//! `cargo run --release -p cce-browser --example wpe_focus`
+
+#[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")]
+fn main() {
+    let page = "data:text/html,<input autofocus><script>\
+        setInterval(() => document.title = 'focus=' + document.hasFocus(), 50)\
+        </script>";
+    let mut host = wpe::WebKitHost::new(url::Url::parse(page).unwrap(), (400, 300));
+    let settle = |h: &mut wpe::WebKitHost, n: u32| {
+        for _ in 0..n {
+            h.pump();
+            std::thread::sleep(std::time::Duration::from_millis(50));
+        }
+    };
+
+    let mut ok = true;
+    let mut expect = |h: &mut wpe::WebKitHost, what: &str, want: bool| {
+        settle(h, 10);
+        let got = h.title();
+        let pass = got.as_deref() == Some(if want { "focus=true" } else { "focus=false" });
+        ok &= pass;
+        println!("{what:<34} {got:?} {}", if pass { "OK" } else { "WRONG" });
+    };
+
+    settle(&mut host, 30);
+    expect(&mut host, "loaded, window not focused", false);
+    host.focus(true);
+    expect(&mut host, "window focused", true);
+    host.focus(false);
+    expect(&mut host, "window unfocused", false);
+    host.focus(true);
+    expect(&mut host, "window refocused", true);
+
+    // A tab opened while the window is focused must arrive focused too.
+    host.open_tab(url::Url::parse(page).unwrap());
+    settle(&mut host, 30);
+    expect(&mut host, "new tab", true);
+    host.activate(0);
+    expect(&mut host, "back to the first tab", true);
+
+    println!("\nfocus: {}", if ok { "OK" } else { "BROKEN" });
+    std::process::exit(if ok { 0 } else { 1 });
+}
diff --git a/src/main.rs b/src/main.rs
index 96a041b..8cfbdeb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2269,6 +2269,8 @@ impl Application for BrowserApp {
     }
 
     fn handle_focus_change(&mut self, focused: bool, needs_rebuild: &mut bool) {
+        // The page's own focus: without it WebKit paints no text caret.
+        self.host.focus(focused);
         // A settings change can move the bar to the other edge, so a reload
         // that changed anything has to redraw the chrome.
         if focused && self.reload_settings() {
diff --git a/src/webview.rs b/src/webview.rs
index 7c629f0..90969b0 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -844,7 +844,7 @@ impl ServoHost {
 
     /// 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) {}
+    pub fn focus(&mut self, _focused: bool) {}
 
     /// Forward a key to the page, modifiers included.
     ///
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index d360d6b..fcee5a9 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -190,6 +190,10 @@ pub struct WebKitHost {
     /// the colour scheme is display-level and force-dark lives in the shared
     /// user-content-manager it was built with.
     spare: Option<(*mut WebKitWebView, *mut WPEView, Rc<TabState>)>,
+    /// Whether the window holds keyboard focus, as last told by [`Self::focus`].
+    /// Kept so a tab made active later inherits it: focus belongs to the view,
+    /// and only the active tab's view should have it.
+    window_focused: bool,
 }
 
 unsafe fn cstr(s: &str) -> CString {
@@ -306,7 +310,12 @@ impl WebKitHost {
                 true
             }));
 
-            let toplevel = wpe_display_create_toplevel(display, 1);
+            // `0` is no view limit, and every tab's view must fit: a full
+            // toplevel refuses `wpe_view_set_toplevel` *silently*. At `1`
+            // (the spike's value) only the first tab ever attached, and every
+            // later one ran without the window's scale (rendering at half
+            // resolution on a 2x output) or its ACTIVE state (no text caret).
+            let toplevel = wpe_display_create_toplevel(display, 0);
             // Scale is 1 until the first `resize` from a real window; the
             // constructor's size is already logical.
             wpe_toplevel_resized(toplevel, size_px.0 as i32, size_px.1 as i32);
@@ -338,6 +347,7 @@ impl WebKitHost {
                 ucm: webkit_user_content_manager_new(),
                 watcher: None,
                 spare: None,
+                window_focused: false,
             };
             // The account watcher's channel, in its own script world. Both
             // halves are registered here, once, on the shared content
@@ -628,6 +638,9 @@ impl WebKitHost {
         }
         unsafe {
             if let Some(old) = self.tabs.get(self.active) {
+                if self.window_focused {
+                    wpe_view_focus_out(old.view);
+                }
                 wpe_view_unmap(old.view);
                 wpe_view_set_visible(old.view, 0);
             }
@@ -638,6 +651,9 @@ impl WebKitHost {
             wpe_view_map(tab.view);
             let (lw, lh) = self.logical_size();
             wpe_view_resized(tab.view, lw, lh);
+            if self.window_focused {
+                wpe_view_focus_in(tab.view);
+            }
         }
     }
 
@@ -1215,15 +1231,30 @@ impl WebKitHost {
         }
     }
 
-    /// 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) {
+    /// Window focus, from the runner's keyboard enter/leave.
+    ///
+    /// WebKit needs **two** things before it paints a text caret: the view
+    /// focused and the toplevel `ACTIVE`. Keystrokes reach a focused field
+    /// with neither, so the only symptom of missing this is a field you can
+    /// type into with no caret in it — which shipped, unnoticed, because
+    /// nothing called this at all. `document.hasFocus()` reads the same pair;
+    /// `examples/wpe_focus.rs` checks it.
+    pub fn focus(&mut self, focused: bool) {
+        self.window_focused = focused;
         unsafe {
-            let view = self.active_tab().view;
-            if focused {
-                wpe_view_focus_in(view)
+            let state = wpe_toplevel_get_state(self.toplevel);
+            let state = if focused {
+                state | WPEToplevelState::WPE_TOPLEVEL_STATE_ACTIVE
             } else {
-                wpe_view_focus_out(view)
+                state & !WPEToplevelState::WPE_TOPLEVEL_STATE_ACTIVE
+            };
+            wpe_toplevel_state_changed(self.toplevel, state);
+            if let Some(tab) = self.tabs.get(self.active) {
+                if focused {
+                    wpe_view_focus_in(tab.view)
+                } else {
+                    wpe_view_focus_out(tab.view)
+                }
             }
         }
     }