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

commitf5e921fc4a7c2ca6d7f6c887256ef1c705e721f5
parent4312fcd707
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 22:02
Raise the on-screen keyboard for a tapped field in the page

The chrome's own fields already claimed text input; a field in the page
did not, so tapping one never brought up cce-keyboard. WebKit asks the
display for an input-method context, so the display now hands out our
own (src/wpe/ime.rs): its focus_in / focus_out / set_cursor_area say
which field in which view is open and where its caret is, for inputs,
textareas and contenteditable in any frame, with no script injected.
display_list claims that caret before the chrome draws, so an open
chrome field still wins.

A press into the page takes back cce-ui's immediate re-announcement of
the open field and makes it 150 ms later, only if the field is still
open: announced at once, it beat WebKit to the press, and a tap on a
link or beside the field flashed the board up.

Verified in shadows at scale 1 and 2 with touch taps: a tapped field
shows the board, a tap or click elsewhere hides it, a pointer click
shows nothing, a tap away from a mouse-focused field shows nothing, a
tap on the already-focused field shows it. inputmode="none" is not
honoured: WebKit 2.52 sends no INHIBIT_OSK hint for it.

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

 CLAUDE.md           |  42 +++++++++++++--
 src/main.rs         |  67 ++++++++++++++++++++++++
 src/wpe/host.rs     |  23 +++++++++
 src/wpe/ime.rs      | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/wpe/mod.rs      |   2 +
 src/wpe/subclass.rs |   3 ++
 6 files changed, 277 insertions(+), 3 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index c1e2430..2bc1c01 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,6 +29,7 @@ Nineteen files, ~14.8k lines. The twelve that carry the design:
 | `src/settings.rs` | the per-app KDL config |
 | `src/vi.rs` | vi mode, after qutebrowser: the modes, the bindings and their parser, hint labels, `:` commands, and the page scripts |
 | `src/accounts.rs` | accounts from cce-secrets: the Secret Service worker, which entries a host earns, saving a new login, and the never-save list |
+| `src/wpe/ime.rs` | which page text field is open, as WebKit reports it to the input-method context our display hands out — what `display_list` claims for the on-screen keyboard |
 | `src/wpe/formwatch.rs` | the page half of account autocomplete: the watcher every frame runs (fields, frame-offset relay, fill asks, sign-in capture) and the events it sends |
 | `src/wpe/damage.rs` | what a frame changed: damage accumulated per view across skipped frames, turned into the regions `pump` reads back |
 | `src/raindrop/` | bookmark sync with Raindrop.io's Unsorted: the three-way merge (`mod.rs`), the REST client (`api.rs`), the worker and status line (`sync.rs`) — design in RAINDROP-SYNC.md |
@@ -569,9 +570,44 @@ things make that work, all against cce-ui's `ime` model:
   tells the shell text is wanted at all. A folded bar paints no URL field, so
   it asks for nothing.
 
-A page's own fields get none of this: with the page focused no chrome field
-reports a caret, so the toolkit leaves text input off and the page gets
-plain keys. `a_composition_is_drawn_at_the_caret_and_never_held` is the test.
+`a_composition_is_drawn_at_the_caret_and_never_held` is the test.
+
+### A page's own fields claim text input too
+
+A focused field in the page — `<input>`, `<textarea>`, `contenteditable`, in
+any frame — is claimed from `display_list` as well
+(`cce_ui::text_input::claim`, before the chrome draws, so an open chrome field
+claims last and wins). That is what raises the compositor's on-screen keyboard
+(cce-compositor `osk.rs`) when the field was tapped. WebKit itself says which
+field is open: the display hands it our own input-method context
+(`create_input_method_context`, `src/wpe/ime.rs`), whose `focus_in` /
+`focus_out` / `set_cursor_area` record the field and its caret per view. No
+script is injected, and nothing else of the context is overridden, so keys
+still reach the page unfiltered. Points that are choices:
+
+- **A press into the page holds back the toolkit's re-announcement**
+  (`defer_page_press`). cce-ui announces an open field again after every press
+  so a tap on an already-focused field raises the board — but it would do so
+  before WebKit has seen the press, so a tap on a link or beside the field
+  (or anywhere, on a page that autofocused its search box) would flash the
+  board up. The browser takes the announcement back and makes it
+  `PAGE_PRESS_SETTLE` (150 ms) later only if the field is still open. A tap
+  that moves the caret is announced at once anyway, as a moved caret.
+- **Not gated on `window_focused`**: the toolkit only enables text input while
+  the compositor gives the surface text-input focus, and `window_focused`
+  follows `wl_keyboard`, which a seat with no keyboard device (a headless
+  shadow) never enters.
+- **`inputmode="none"` is not honoured yet**: the field is skipped when WebKit
+  sets `INHIBIT_OSK`, but WebKit 2.52 sends only `SPELLCHECK` for it.
+- **A composition is not shown in the page.** With an input method running,
+  a committed string should arrive as typed keys, as it does for the chrome
+  (not yet tried with fcitx5/IBus); the preedit is not drawn (WebKit would take it through the same context's
+  `get_preedit_string` and its signals, which nothing emits yet).
+
+Verified in shadows at scale 1 and 2 with `ctl touch tap`: a tapped field
+spawns `cce-keyboard show`, a tap or click elsewhere `hide`, a pointer click
+shows nothing, a tap away from a mouse-focused field shows nothing, and a
+tap on the field that already has focus shows the board.
 
 ### The wheel eases; the trackpad does not
 
diff --git a/src/main.rs b/src/main.rs
index b46cfd3..eec6f8f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -100,6 +100,12 @@ 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);
+/// How long a press into the page has to reach WebKit and move its focus
+/// before an open page field is announced again (`defer_page_press`). Well
+/// inside the compositor's 800 ms touch window, which the announcement has
+/// to land in to raise the on-screen keyboard.
+#[cfg(feature = "wpe")]
+const PAGE_PRESS_SETTLE: std::time::Duration = std::time::Duration::from_millis(150);
 
 /// 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
@@ -896,6 +902,10 @@ struct BrowserApp {
     /// 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>,
+    /// When a press into an open page field is announced again
+    /// (`defer_page_press`).
+    #[cfg(feature = "wpe")]
+    page_press_settle: 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.
@@ -2444,10 +2454,48 @@ impl BrowserApp {
         if !self.page_buttons.contains(&button) {
             self.page_buttons.push(button);
         }
+        #[cfg(feature = "wpe")]
+        self.defer_page_press();
         let s = self.scale as f32;
         self.host.mouse_button_ui(button, true, pos.x * s, pos.y * s);
     }
 
+    /// A press into the page while one of its text fields is open. The
+    /// toolkit announces an open field again after every press
+    /// (`ime::note_press`), which is what lets a tap on a field that already
+    /// has focus raise the on-screen keyboard — but here it would announce
+    /// the field before WebKit has even seen the press, so a tap that is
+    /// about to take focus away (a link, the page around the field, a page
+    /// that autofocused its search box) would flash the board up. The
+    /// announcement is taken back and made once the page has had
+    /// `PAGE_PRESS_SETTLE` to answer, and only if the field is still open.
+    /// A tap that moves the caret is announced without waiting, as a moved
+    /// caret.
+    #[cfg(feature = "wpe")]
+    fn defer_page_press(&mut self) {
+        if self.host.page_text_field().is_none() || !cce_ui::ime::take_press() {
+            return;
+        }
+        self.page_press_settle = Some(std::time::Instant::now() + PAGE_PRESS_SETTLE);
+        let tx = self.sender.clone();
+        std::thread::spawn(move || {
+            std::thread::sleep(PAGE_PRESS_SETTLE);
+            let _ = tx.send(Message::Spin);
+        });
+    }
+
+    /// Make the announcement `defer_page_press` held back, once its time is
+    /// up. Returns whether a frame is needed to make it.
+    #[cfg(feature = "wpe")]
+    fn settle_page_press(&mut self) -> bool {
+        let Some(at) = self.page_press_settle else { return false };
+        if std::time::Instant::now() < at {
+            return false;
+        }
+        self.page_press_settle = None;
+        self.host.page_text_field().is_some() && cce_ui::ime::note_press()
+    }
+
     /// Give the page the release it is owed, wherever the pointer ended up
     /// and whatever the chrome is about to do with this click.
     ///
@@ -4007,6 +4055,8 @@ impl Application for BrowserApp {
             chrome_open: false,
             chrome_t: 0.0,
             chrome_peek: None,
+            #[cfg(feature = "wpe")]
+            page_press_settle: None,
             peek_pointer: (0.0, 0.0),
             dot_hover: false,
             loading: true,
@@ -4125,6 +4175,16 @@ impl Application for BrowserApp {
         match msg {
             Message::Spin => {
                 let (new_frame, dirty) = self.host.pump();
+                // A page field opening or closing has to reach the frame
+                // that claims it, even when the page repaints nothing.
+                #[cfg(feature = "wpe")]
+                if self.host.take_page_text_field_changed() {
+                    *needs_rebuild = true;
+                }
+                #[cfg(feature = "wpe")]
+                if self.settle_page_press() {
+                    *needs_rebuild = true;
+                }
                 #[cfg(feature = "wpe")]
                 if self.sync_modal() {
                     *needs_rebuild = true;
@@ -5226,6 +5286,13 @@ impl Application for BrowserApp {
             };
             pc.text("Loading...", bar_margin(), y, 13.0, TEXT_DIM);
         }
+        // A text field in the page has focus: claim its caret, so a tap on it
+        // raises the on-screen keyboard. First, so a chrome field drawn over
+        // the page (URL bar, dialog, vi line) claims last and wins.
+        #[cfg(feature = "wpe")]
+        if let Some((x, y, w, h)) = self.host.page_text_field() {
+            cce_ui::text_input::claim(x, y, w, h);
+        }
 
         // The bar plate — or the shape it is unfolding through. Nothing but
         // the corner control shows while closed. Blur-behind, frosting the
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index dbc0a26..e8d1e49 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -780,6 +780,29 @@ impl WebKitHost {
         self.prompts.borrow_mut().vi_focus.take()
     }
 
+    /// The page text field open for typing in the active tab: its caret in
+    /// window logical px, or the whole page until WebKit has placed the
+    /// caret. What `display_list` claims, so a tap on a page field raises
+    /// the on-screen keyboard.
+    ///
+    /// Not gated on `window_focused`: the toolkit enables text input only
+    /// while the compositor has given this surface the text-input focus,
+    /// which already says the same thing — and `window_focused` follows
+    /// `wl_keyboard`, which a seat with no keyboard device never enters.
+    pub fn page_text_field(&self) -> Option<(f32, f32, f32, f32)> {
+        let tab = self.tabs.get(self.active)?;
+        let field = super::ime::field(tab.view)?;
+        let (w, h) = self.logical_size();
+        let (x, y, cw, ch) = field.unwrap_or((0, 0, w, h));
+        Some((x as f32, y as f32, cw.max(1) as f32, ch.max(1) as f32))
+    }
+
+    /// Whether a page field opened, closed or moved since the last call:
+    /// the frame that claims it has to be built.
+    pub fn take_page_text_field_changed(&self) -> bool {
+        super::ime::take_changed()
+    }
+
     /// Run `script` in the active tab's top frame, in the vi world, and queue
     /// its result as a string under `tag` for [`Self::take_vi_result`]. A
     /// failed script answers with an empty string, so a caller waiting on
diff --git a/src/wpe/ime.rs b/src/wpe/ime.rs
new file mode 100644
index 0000000..88ae483
--- /dev/null
+++ b/src/wpe/ime.rs
@@ -0,0 +1,143 @@
+//! Which page text field is open for typing, as WebKit itself reports it.
+//!
+//! WebKit tells the embedder about an editable element through an input-method
+//! context: `focus_in` when one takes focus in a focused view, `focus_out` when
+//! it loses it, `set_cursor_area` as its caret moves. It asks the *display* for
+//! that context (`create_input_method_context`), so supplying our own is all it
+//! takes to hear about a field in any page, frame or shadow root — `<input>`,
+//! `<textarea>` and `contenteditable` alike — with no script injected.
+//!
+//! The browser claims the field from `display_list` with it
+//! (`cce_ui::text_input::claim`), which is what raises the compositor's
+//! on-screen keyboard after a tap, exactly as it does for the chrome's own
+//! fields.
+//!
+//! Only the announcements are taken. Every other vfunc is left to the base
+//! class, so key events are not filtered and reach the page as they did before
+//! this context existed.
+
+use std::cell::{Cell, RefCell};
+use std::ffi::c_void;
+
+use super::ffi::*;
+
+/// A page field with focus: its caret in the view's logical px, once WebKit
+/// has said where it is.
+#[derive(Clone, Copy, Default)]
+struct Field {
+    view: usize,
+    focused: bool,
+    caret: Option<(i32, i32, i32, i32)>,
+}
+
+thread_local! {
+    /// Per context, keyed by its pointer. A context is one view's for life.
+    static FIELDS: RefCell<Vec<(usize, Field)>> = const { RefCell::new(Vec::new()) };
+    /// A field opened, closed or moved since the browser last asked.
+    static CHANGED: Cell<bool> = const { Cell::new(false) };
+}
+
+static mut TYPE: GType = 0;
+static mut PARENT_FINALIZE: Option<unsafe extern "C" fn(*mut GObject)> = None;
+
+unsafe fn context_type() -> GType {
+    if TYPE == 0 {
+        TYPE = super::subclass::register_subclass(
+            wpe_input_method_context_get_type(),
+            "CceWpeInputMethodContext",
+            class_init,
+        );
+    }
+    TYPE
+}
+
+fn update(ctx: *mut WPEInputMethodContext, f: impl FnOnce(&mut Field)) {
+    let view = unsafe { wpe_input_method_context_get_view(ctx) } as usize;
+    FIELDS.with(|m| {
+        let mut m = m.borrow_mut();
+        let at = match m.iter().position(|(c, _)| *c == ctx as usize) {
+            Some(at) => at,
+            None => {
+                m.push((ctx as usize, Field { view, ..Field::default() }));
+                m.len() - 1
+            }
+        };
+        f(&mut m[at].1);
+    });
+    CHANGED.set(true);
+}
+
+unsafe extern "C" fn focus_in(ctx: *mut WPEInputMethodContext) {
+    log::debug!("page field: focus in");
+    update(ctx, |f| f.focused = true);
+}
+
+unsafe extern "C" fn focus_out(ctx: *mut WPEInputMethodContext) {
+    log::debug!("page field: focus out");
+    update(ctx, |f| {
+        f.focused = false;
+        f.caret = None;
+    });
+}
+
+unsafe extern "C" fn set_cursor_area(
+    ctx: *mut WPEInputMethodContext,
+    x: i32,
+    y: i32,
+    w: i32,
+    h: i32,
+) {
+    update(ctx, |f| f.caret = Some((x, y, w, h)));
+}
+
+unsafe extern "C" fn finalize(obj: *mut GObject) {
+    FIELDS.with(|m| m.borrow_mut().retain(|(c, _)| *c != obj as usize));
+    CHANGED.set(true);
+    if let Some(parent) = PARENT_FINALIZE {
+        parent(obj);
+    }
+}
+
+unsafe extern "C" fn class_init(class: *mut c_void, _data: *mut c_void) {
+    let c = class as *mut WPEInputMethodContextClass;
+    (*c).focus_in = Some(focus_in);
+    (*c).focus_out = Some(focus_out);
+    (*c).set_cursor_area = Some(set_cursor_area);
+    PARENT_FINALIZE = (*c).parent_class.finalize;
+    (*c).parent_class.finalize = Some(finalize);
+}
+
+/// `WPEDisplayClass::create_input_method_context`.
+pub(super) unsafe extern "C" fn create_context(
+    _d: *mut WPEDisplay,
+    view: *mut WPEView,
+) -> *mut WPEInputMethodContext {
+    let prop = std::ffi::CString::new("view").unwrap();
+    g_object_new(context_type(), prop.as_ptr(), view, std::ptr::null::<std::ffi::c_char>())
+        as *mut WPEInputMethodContext
+}
+
+/// The open field in `view`, if one is: its caret's rectangle in the view's
+/// logical px, or `None` inside when WebKit has not placed the caret yet.
+///
+/// A field WebKit marks `INHIBIT_OSK` (the page draws its own keyboard, or
+/// wants none) is not reported. The hints are read now rather than at
+/// `focus_in`, because moving from one field to the next says no focus in
+/// or out, only a new caret. WebKit 2.52 does not set that hint yet:
+/// `inputmode="none"` arrives as plain `SPELLCHECK` (measured), so such a
+/// field is claimed like any other until it does.
+pub(super) fn field(view: *mut WPEView) -> Option<Option<(i32, i32, i32, i32)>> {
+    let (ctx, field) = FIELDS.with(|m| {
+        m.borrow().iter().find(|(_, f)| f.view == view as usize && f.focused).copied()
+    })?;
+    let hints = unsafe { wpe_input_method_context_get_input_hints(ctx as *mut WPEInputMethodContext) };
+    if hints & WPEInputHints::WPE_INPUT_HINT_INHIBIT_OSK != 0 {
+        return None;
+    }
+    Some(field.caret)
+}
+
+/// Whether any field opened, closed or moved since the last call.
+pub(super) fn take_changed() -> bool {
+    CHANGED.replace(false)
+}
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
index d259308..43ccfd7 100644
--- a/src/wpe/mod.rs
+++ b/src/wpe/mod.rs
@@ -12,6 +12,8 @@ pub mod ffi {
 mod subclass;
 /// What a frame changed, so only that much is read back and uploaded.
 mod damage;
+/// Which page text field is open, for the on-screen keyboard.
+mod ime;
 mod input;
 mod glib_source;
 mod host;
diff --git a/src/wpe/subclass.rs b/src/wpe/subclass.rs
index 37c1443..b90d5e0 100644
--- a/src/wpe/subclass.rs
+++ b/src/wpe/subclass.rs
@@ -215,6 +215,9 @@ unsafe extern "C" fn display_class_init(class: *mut c_void, _data: *mut c_void)
     // Without this, WebKit has no clipboard at all: Ctrl+V in a page reads
     // nothing and Ctrl+C writes nowhere, silently.
     (*c).get_clipboard = Some(display_get_clipboard);
+    // How a page says a text field has focus: without it, the browser cannot
+    // tell the compositor to raise the on-screen keyboard (`ime.rs`).
+    (*c).create_input_method_context = Some(super::ime::create_context);
 }
 
 // ---- clipboard ----