web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
Honour inputmode="none" in page fields
A field marked inputmode="none" (the page draws its own keyboard, or
wants none) raised the on-screen keyboard like any other: WebKit 2.52
has a hint for it, INHIBIT_OSK, but never sets it (the field arrives as
plain SPELLCHECK). So a small watcher now runs in every frame, in a
private script world, and reports "none" or "text" on each focusin; the
claim reads its last report. A field WebKit has just opened waits for
that report (the two land within ms of each other, either order) or
100 ms, so the board is never up before the page has said it wants
none.
The watcher reports only from a visible document, which keeps
background tabs off the shared channel. Not document.hasFocus(): that
also needs the window to hold the keyboard, and is false throughout a
headless shadow, which has no keyboard device.
Verified in a scale-1 shadow with touch taps: a none field, top-level
or in a frame, shows nothing when tapped fresh or again, hides the
board when moved to from an open field, and moving from it to a normal
field shows the board. The scale-2 matrix for normal fields is
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 20 +++++++--
src/wpe/host.rs | 2 +
src/wpe/ime.rs | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++---
3 files changed, 145 insertions(+), 10 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index d6f712b..b2968a7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -610,8 +610,19 @@ still reach the page unfiltered. Points that are choices:
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.
+- **`inputmode="none"` comes from a watcher, not WebKit.** WebKit has a hint
+ for it (`INHIBIT_OSK`, still honoured) but 2.52 sends only `SPELLCHECK`. So
+ `WATCH_JS` runs in every frame in a private world (`cce-ime`, channel
+ `cceIme`) and reports `none`/`text` on each `focusin`, and a field WebKit
+ has just opened is not claimed until that report is in (they land within
+ ms of each other, either order; a report up to `REPORT_EARLY` before the
+ `focus_in` counts) or `REPORT_WAIT` (100 ms) has passed. Claimed at once,
+ the board would be up before the page said it wants none. Moving between
+ fields sends no `focus_in`, only a new report, which applies at once.
+ The watcher speaks only while `document.visibilityState` is visible —
+ that is what keeps background tabs off the shared channel — never
+ `hasFocus()`, which also needs the window to hold the keyboard and is
+ false all through a headless shadow (no keyboard device).
- **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
@@ -620,7 +631,10 @@ still reach the page unfiltered. Points that are choices:
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.
+tap on the field that already has focus shows the board; an
+`inputmode="none"` field — top-level or in a frame, tapped fresh, tapped
+again, or moved to from an open field — shows nothing (or hides the board),
+and moving from it to a normal field shows it.
### The wheel eases; the trackpad does not
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index b41447d..a065470 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -573,6 +573,8 @@ impl WebKitHost {
// manager every tab is built against.
host.register_account_channel();
host.register_vi_channel();
+ // Which page fields want no on-screen keyboard (`ime.rs`).
+ super::ime::install_watch(host.ucm);
host.open_tab(url);
host
}
diff --git a/src/wpe/ime.rs b/src/wpe/ime.rs
index 88ae483..ee3a35e 100644
--- a/src/wpe/ime.rs
+++ b/src/wpe/ime.rs
@@ -15,9 +15,18 @@
//! 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.
+//!
+//! The one thing the context does not say is `inputmode="none"` — the page
+//! draws its own keyboard, or wants none. WebKit has a hint for it
+//! (`INHIBIT_OSK`) but 2.52 never sets it (the field arrives as plain
+//! `SPELLCHECK`, measured), so a small watcher in every frame reports it
+//! instead ([`WATCH_JS`]), and a freshly opened field is not claimed until
+//! that report is in or [`REPORT_WAIT`] has passed. Claimed at once, the
+//! board would be up before the page could say it wants none.
use std::cell::{Cell, RefCell};
use std::ffi::c_void;
+use std::time::{Duration, Instant};
use super::ffi::*;
@@ -28,13 +37,55 @@ struct Field {
view: usize,
focused: bool,
caret: Option<(i32, i32, i32, i32)>,
+ /// When WebKit said this field opened.
+ since: Option<Instant>,
}
+/// The script world the `inputmode` watcher runs in: page script cannot see
+/// it, nor post on its channel.
+const WORLD: &str = "cce-ime";
+/// The channel it reports on.
+const CHANNEL: &str = "cceIme";
+
+/// Runs in every frame, and when focus moves reports whether the focused
+/// element asks for no keyboard: `"none"` or `"text"`. Only the shown tab
+/// speaks — every tab shares the channel, and a background tab is unmapped,
+/// so hidden. Visibility rather than `document.hasFocus()`, which also
+/// needs the window to hold the keyboard: WebKit opens a field and the board
+/// can follow a tap without that (a seat with no keyboard device). A frame
+/// whose focus is inside a child frame leaves it to the child, which runs
+/// its own copy, so a field in a cross-origin frame is reported too.
+const WATCH_JS: &str = r#"(() => {
+ const h = window.webkit && window.webkit.messageHandlers;
+ if (!h || !h.cceIme) return;
+ // After the event, not in it: mid-move, activeElement is still the old one.
+ document.addEventListener('focusin', () => setTimeout(() => {
+ if (document.visibilityState !== 'visible') return;
+ let el = document.activeElement;
+ while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
+ if (!el || el.tagName === 'IFRAME' || el.tagName === 'FRAME') return;
+ const none = (el.inputMode || '').toLowerCase() === 'none';
+ try { h.cceIme.postMessage(none ? 'none' : 'text'); } catch (e) {}
+ }, 0), true);
+})();"#;
+
+/// How long a freshly opened field waits for the watcher before it is
+/// claimed anyway. The report comes from the same focus change as WebKit's
+/// `focus_in` and lands within a few ms of it, either side; this only
+/// bounds a page the watcher cannot run in.
+const REPORT_WAIT: Duration = Duration::from_millis(100);
+
+/// A report this much older than WebKit's `focus_in` is about an earlier
+/// field, not this one.
+const REPORT_EARLY: Duration = Duration::from_millis(300);
+
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) };
+ /// The watcher's last report: when, and whether it was `inputmode=none`.
+ static REPORT: Cell<Option<(Instant, bool)>> = const { Cell::new(None) };
}
static mut TYPE: GType = 0;
@@ -69,7 +120,17 @@ fn update(ctx: *mut WPEInputMethodContext, f: impl FnOnce(&mut Field)) {
unsafe extern "C" fn focus_in(ctx: *mut WPEInputMethodContext) {
log::debug!("page field: focus in");
- update(ctx, |f| f.focused = true);
+ update(ctx, |f| {
+ f.focused = true;
+ f.since = Some(Instant::now());
+ });
+ // Wake the loop when the wait for the watcher runs out: an idle page
+ // sends nothing else that would.
+ g_timeout_add_once(REPORT_WAIT.as_millis() as u32 + 10, Some(on_report_due), std::ptr::null_mut());
+}
+
+unsafe extern "C" fn on_report_due(_data: gpointer) {
+ CHANGED.set(true);
}
unsafe extern "C" fn focus_out(ctx: *mut WPEInputMethodContext) {
@@ -120,12 +181,12 @@ pub(super) unsafe extern "C" fn create_context(
/// 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.
+/// A field that asks for no keyboard is not reported: `inputmode="none"`,
+/// by the watcher's last report or WebKit's `INHIBIT_OSK` hint, and a field
+/// just opened is held back until the watcher has spoken (see the module
+/// notes). Both 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, and a
+/// new report.
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()
@@ -134,9 +195,67 @@ pub(super) fn field(view: *mut WPEView) -> Option<Option<(i32, i32, i32, i32)>>
if hints & WPEInputHints::WPE_INPUT_HINT_INHIBIT_OSK != 0 {
return None;
}
+ let since = field.since.unwrap_or_else(Instant::now);
+ match REPORT.get() {
+ Some((at, none)) if at + REPORT_EARLY >= since => {
+ if none {
+ return None;
+ }
+ }
+ _ if since.elapsed() < REPORT_WAIT => return None,
+ _ => {}
+ }
Some(field.caret)
}
+/// Install the `inputmode` watcher on the content manager every tab is
+/// built against, with its channel. Once per host, for its whole life.
+pub(super) unsafe fn install_watch(ucm: *mut WebKitUserContentManager) {
+ let (name, world) = (cstr(CHANNEL), cstr(WORLD));
+ if webkit_user_content_manager_register_script_message_handler(ucm, name.as_ptr(), world.as_ptr()) == 0 {
+ log::warn!("could not register the inputmode channel");
+ return;
+ }
+ let signal = cstr(&format!("script-message-received::{CHANNEL}"));
+ g_signal_connect_data(
+ ucm as *mut _,
+ signal.as_ptr(),
+ Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(on_report as *const () as usize)),
+ std::ptr::null_mut(),
+ None,
+ 0,
+ );
+ let source = cstr(WATCH_JS);
+ // Every frame, from document start: a field is often in a frame, and a
+ // page can focus one before its own load finishes.
+ let script = webkit_user_script_new_for_world(
+ source.as_ptr(),
+ WebKitUserContentInjectedFrames::WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
+ WebKitUserScriptInjectionTime::WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
+ world.as_ptr(),
+ std::ptr::null(),
+ std::ptr::null(),
+ );
+ webkit_user_content_manager_add_script(ucm, script);
+ webkit_user_script_unref(script);
+}
+
+unsafe extern "C" fn on_report(_ucm: *mut WebKitUserContentManager, value: *mut JSCValue, _data: gpointer) {
+ let raw = jsc_value_to_string(value);
+ if raw.is_null() {
+ return;
+ }
+ let none = std::ffi::CStr::from_ptr(raw).to_bytes() == b"none";
+ g_free(raw as *mut _);
+ log::debug!("page field: inputmode {}", if none { "none" } else { "text" });
+ REPORT.set(Some((Instant::now(), none)));
+ CHANGED.set(true);
+}
+
+fn cstr(s: &str) -> std::ffi::CString {
+ std::ffi::CString::new(s).expect("no NUL")
+}
+
/// Whether any field opened, closed or moved since the last call.
pub(super) fn take_changed() -> bool {
CHANGED.replace(false)