web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
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 ----