GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
IME on Wayland: text-input-v3
The Wayland shell's half of input-method composition (crate::ime), as the
hidden textarea is the browser's and NSTextInputClient the AppKit shell's.
The compositor relays it to an input-method-v2 client (fcitx5, IBus).
- backend/text_input.rs: the protocol's decisions, pure and tested with no
compositor. TextInput::plan enables the text input while the seat's
text-input focus is on our surface and a widget is editing, with the
caret as the cursor rectangle (surface px: the app's times a forced
scale), re-sends it when the caret moves, disables with nothing editing,
and disables-then-enables for a composition a widget dropped; every
change is one counted commit. Batch applies a done in the protocol's
order: the old composition out, the commit typed, the new one in.
- window_runner: binds zwp_text_input_manager_v3, makes the text input
with the first keyboard, dispatches its events through the driver
(preedit, commit_text), and syncs after each render.
Verified under the headless sway with a scriptable input-method-v2 client
standing in for an IME: no activation until a box is clicked into; a
composition shown underlined at the caret; the commit replacing it; a
cancel; a press mid-composition dropping it with a disable and enable;
Escape disabling. WAYLAND_DEBUG shows the cursor rectangle following the
caret at every step and each done's serial equal to the commits sent.
Linux: lib suite 547 passed (the 2 known failures), plate golden
identical, the 24-step sway run 0 px (sway routes text-input focus only
while an input method is bound); check-wasm and check-mac ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ
CLAUDE.md | 32 ++++++-
src/backend/mod.rs | 2 +
src/backend/text_input.rs | 214 +++++++++++++++++++++++++++++++++++++++++++
src/backend/window_runner.rs | 109 ++++++++++++++++++++++
src/ime.rs | 8 +-
5 files changed, 358 insertions(+), 7 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 0749387..12ef531 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -335,9 +335,33 @@ the history, and the box takes no key while it composes. It is applied in `prepa
and at the top of `handle_key`, against `ime::generation`; a press, or editing ending,
drops it and asks the input method to cancel (`ime::request_reset`). A composition begun
over a selection replaces it, as typing would. `a_composition_is_shown_in_place_and_the_
-commit_is_typed` is the test. Not there yet: the Wayland shell does not speak
-`text-input-v3`, so on Linux nothing composes; and `LineEdit` and the `DocEditor` take
-the commit but do not show the composition.
+commit_is_typed` is the test. Not there yet: `LineEdit` and the `DocEditor` take the
+commit but do not show the composition; no shell sends surrounding text, so an input
+method's `delete_surrounding_text` (text-input-v3) is not applied; and a password box is
+announced with the normal content purpose.
+
+**On Wayland it is `text-input-v3`** (`backend/text_input.rs`, since 2026-10-05), relayed
+by the compositor to an `input-method-v2` client (fcitx5, IBus's Wayland frontend). The
+text input is the first keyboard seat's, made with the keyboard. After each render
+(`EngineState::sync_text_input`) it is ENABLED while the seat's text-input focus is on our
+surface (`enter`) and a widget is editing (`ime::caret`), with a normal content type and
+the caret as the cursor rectangle (surface px — the app's logical px times a forced scale,
+as pointer input is divided), re-sent when the caret moves; DISABLED when nothing is
+editing; and disabled-then-enabled for a composition a widget dropped (`ime::take_reset`),
+which resets the input method. Every change is one `commit`, counted (`TextInput::commits`,
+what a current `done`'s serial is). `preedit_string` / `commit_string` /
+`delete_surrounding_text` are double-buffered and applied on `done` in the protocol's
+order (`Batch::apply_order`: the old composition out, the commit typed, the new one in;
+a batch with no `preedit_string` ends the composition, a cursor of -1 hides it); `leave`
+drops the composition. The decisions are pure (`TextInput::plan`, `Batch`) and tested
+with no compositor (`backend::text_input::tests`). Verified end to end under the headless
+sway with a scriptable `input-method-v2` client standing in for fcitx5 (2026-10-05): no
+activation until a box is clicked into; a composition shown underlined at the caret; the
+commit replacing it; a cancel; a press mid-composition dropping it with a
+disable-and-enable; Escape disabling — and under `WAYLAND_DEBUG` the cursor rectangle
+following the caret through every step, each `done`'s serial equal to the commits sent.
+Sway routes text-input focus only while an input method is bound, so with none (the
+24-step harness) nothing changes: 0 px.
Wayland protocol bindings are generated **inline at compile time** by `wayland-scanner` macros in
`src/protocol.rs` from `protocol/*.xml` (`cce-inspector-v1`, `cce-window-management-v1`) — there is
@@ -1402,7 +1426,7 @@ cce-system-interface) to confirm behavior, not just the test suite.
`stage_renderer`, the draw). A routing change belongs in `driver.rs`, a change to
what a frame contains in `frame.rs` and a pacing change in `shell.rs`, never in the
Wayland code. A second shell implements `Shell` and calls `Pacer::turn` from its own
- loop (an animation frame, a run-loop observer), sleeping or scheduling for the `Step`. `menu_popup.rs` and `dnd.rs` are Wayland-only.
+ loop (an animation frame, a run-loop observer), sleeping or scheduling for the `Step`. `menu_popup.rs`, `dnd.rs` and `text_input.rs` (`text-input-v3`, the input method's way in) are Wayland-only.
- `draw/` — what a renderer draws, with no renderer in it (since 2026-10-04): `Frame2D`,
`Batch2D`, `PlatePush` and `batch_push_constants` (the one layout of a batch's 32-float
parameter block — Vulkan pushes it, a renderer without push constants puts it in a
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index fe6be70..7d0c870 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -12,6 +12,8 @@ pub mod dnd;
#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod menu_popup;
#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
+pub mod text_input;
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod window_runner;
diff --git a/src/backend/text_input.rs b/src/backend/text_input.rs
new file mode 100644
index 0000000..d1c2e3d
--- /dev/null
+++ b/src/backend/text_input.rs
@@ -0,0 +1,214 @@
+//! `text-input-unstable-v3`: the Wayland shell's half of input-method
+//! composition (`crate::ime`), as the hidden textarea is the browser's and
+//! `NSTextInputClient` the AppKit shell's.
+//!
+//! The compositor relays between this object and the input method (an
+//! `input-method-v2` client: fcitx5, IBus through its Wayland frontend, …):
+//!
+//! - **What we tell it.** While a widget is editing text (`ime::caret` is
+//! set) and the seat's text-input focus is on our surface (`enter`), the
+//! text input is ENABLED, with a normal content type and the caret as the
+//! cursor rectangle (surface px), re-sent when the caret moves; otherwise
+//! it is disabled. A composition a widget dropped (`ime::take_reset`) is
+//! cancelled by disabling and enabling again, which resets the input
+//! method's state. Every state change is one `commit`, counted.
+//! - **What it tells us.** `preedit_string`, `commit_string` and
+//! `delete_surrounding_text` are double-buffered and applied on `done`, in
+//! the protocol's order: the old composition out, the commit typed
+//! (`Driver::commit_text`), the new composition in (`Driver::preedit`).
+//! A batch with no `preedit_string` ends the composition. We send no
+//! surrounding text, so a deletion has nothing to count its bytes in and
+//! is not applied. A `done` whose serial is behind our commits still
+//! applies its text — the protocol asks only that it not change our state.
+//! - `leave` drops any composition, as the protocol asks; the compositor
+//! ignores our requests until the next `enter`.
+//!
+//! The pure part — what a batch does, and what state to send — is
+//! [`Batch::apply_order`] and [`TextInput::plan`], tested with no
+//! compositor.
+
+use crate::ime::Preedit;
+
+/// The double-buffered text a `done` applies.
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
+pub struct Batch {
+ pub preedit: Option<Preedit>,
+ pub commit: Option<String>,
+ pub delete: Option<(u32, u32)>,
+}
+
+/// One step of applying a batch.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Apply {
+ Preedit(Option<Preedit>),
+ Commit(String),
+}
+
+impl Batch {
+ /// A `preedit_string` event: the protocol's cursor (bytes, -1 for
+ /// hidden) as `Preedit`'s.
+ pub fn set_preedit(&mut self, text: Option<String>, begin: i32, end: i32) {
+ let text = text.unwrap_or_default();
+ let cursor = (begin >= 0 && end >= 0).then(|| (begin as usize, end as usize));
+ self.preedit = (!text.is_empty()).then(|| Preedit::new(text, cursor));
+ }
+
+ /// The batch as the protocol's `done` orders it: the old composition
+ /// out, the commit typed, the new composition in.
+ pub fn apply_order(self) -> Vec<Apply> {
+ let mut steps = Vec::new();
+ if let Some(text) = self.commit.filter(|t| !t.is_empty()) {
+ steps.push(Apply::Preedit(None));
+ steps.push(Apply::Commit(text));
+ }
+ steps.push(Apply::Preedit(self.preedit));
+ steps
+ }
+}
+
+/// What to send the compositor this turn.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Send {
+ /// Nothing changed.
+ Nothing,
+ /// `enable` (after a `disable` if `reset`), the content type, the
+ /// cursor rectangle, `commit`.
+ Enable { rect: [i32; 4], reset: bool },
+ /// The cursor rectangle moved: it, then `commit`.
+ Move { rect: [i32; 4] },
+ /// `disable`, `commit`.
+ Disable,
+}
+
+/// The text input's state on our side.
+#[derive(Debug, Default)]
+pub struct TextInput {
+ /// The seat's text-input focus is on our surface.
+ pub entered: bool,
+ /// We have committed an `enable` (and no `disable` since).
+ pub enabled: bool,
+ /// The cursor rectangle last committed.
+ pub sent_rect: Option<[i32; 4]>,
+ /// `commit` requests issued: what a current `done` carries as its serial.
+ pub commits: u32,
+ /// The batch since the last `done`.
+ pub pending: Batch,
+}
+
+impl TextInput {
+ /// What the state should become, given the editing widget's caret
+ /// (`ime::caret`, in the app's logical px), the surface's px per
+ /// logical px, and whether a widget asked for its composition to be
+ /// cancelled. Records the new state as sent.
+ pub fn plan(&mut self, caret: Option<[f32; 4]>, surface_scale: f32, reset: bool) -> Send {
+ let rect = caret.map(|[x, y, w, h]| {
+ let s = surface_scale;
+ [(x * s).round() as i32, (y * s).round() as i32, ((w * s).round() as i32).max(1), ((h * s).round() as i32).max(1)]
+ });
+ let send = match (self.entered, rect) {
+ (true, Some(rect)) if !self.enabled || reset => Send::Enable { rect, reset: self.enabled && reset },
+ (true, Some(rect)) if self.sent_rect != Some(rect) => Send::Move { rect },
+ (true, Some(_)) => Send::Nothing,
+ (true, None) if self.enabled => Send::Disable,
+ _ => Send::Nothing,
+ };
+ match send {
+ Send::Enable { rect, reset } => {
+ self.enabled = true;
+ self.sent_rect = Some(rect);
+ // A reset commits its disable before the enable.
+ self.commits += if reset { 2 } else { 1 };
+ }
+ Send::Move { rect } => {
+ self.sent_rect = Some(rect);
+ self.commits += 1;
+ }
+ Send::Disable => {
+ self.enabled = false;
+ self.sent_rect = None;
+ self.commits += 1;
+ }
+ Send::Nothing => {}
+ }
+ send
+ }
+
+ /// `enter`: our surface has the text-input focus; the next plan enables
+ /// if a widget is editing.
+ pub fn enter(&mut self) {
+ self.entered = true;
+ self.enabled = false;
+ self.sent_rect = None;
+ }
+
+ /// `leave`: the focus went; the compositor disabled us, and ignores us
+ /// until the next `enter`.
+ pub fn leave(&mut self) {
+ self.entered = false;
+ self.enabled = false;
+ self.sent_rect = None;
+ self.pending = Batch::default();
+ }
+
+ /// `done`: the batch to apply, the pending state back to initial.
+ pub fn done(&mut self) -> Batch {
+ std::mem::take(&mut self.pending)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_done_ends_the_composition_types_the_commit_and_starts_the_next() {
+ let mut b = Batch::default();
+ b.commit = Some("日本".into());
+ b.set_preedit(Some("ご".into()), 3, 3);
+ assert_eq!(
+ b.apply_order(),
+ vec![
+ Apply::Preedit(None),
+ Apply::Commit("日本".into()),
+ Apply::Preedit(Some(Preedit::new("ご", Some((3, 3))))),
+ ]
+ );
+ // A batch with no preedit_string ends the composition; a hidden
+ // cursor is none.
+ assert_eq!(Batch::default().apply_order(), vec![Apply::Preedit(None)]);
+ let mut b = Batch::default();
+ b.set_preedit(Some("か".into()), -1, -1);
+ assert_eq!(b.apply_order(), vec![Apply::Preedit(Some(Preedit::new("か", None)))]);
+ }
+
+ #[test]
+ fn enabled_only_while_focused_and_editing_and_every_change_one_commit() {
+ let mut ti = TextInput::default();
+ let caret = Some([10.0, 20.0, 1.5, 16.0]);
+ // Editing, but not yet entered: nothing to say.
+ assert_eq!(ti.plan(caret, 1.0, false), Send::Nothing);
+ ti.enter();
+ assert_eq!(ti.plan(caret, 1.0, false), Send::Enable { rect: [10, 20, 2, 16], reset: false });
+ assert_eq!(ti.plan(caret, 1.0, false), Send::Nothing);
+ // The caret moves; at a forced scale of 2 the surface is twice the app.
+ assert_eq!(ti.plan(Some([30.0, 20.0, 1.5, 16.0]), 2.0, false), Send::Move { rect: [60, 40, 3, 32] });
+ // A widget dropped its composition: disable and enable again.
+ assert_eq!(ti.plan(Some([30.0, 20.0, 1.5, 16.0]), 2.0, true), Send::Enable { rect: [60, 40, 3, 32], reset: true });
+ // Nothing editing.
+ assert_eq!(ti.plan(None, 1.0, false), Send::Disable);
+ assert_eq!(ti.plan(None, 1.0, false), Send::Nothing);
+ assert_eq!(ti.commits, 5, "enable, move, disable + enable, disable");
+ // The focus leaves; editing again enables nothing until it is back.
+ ti.leave();
+ assert_eq!(ti.plan(caret, 1.0, false), Send::Nothing);
+ ti.enter();
+ assert!(matches!(ti.plan(caret, 1.0, false), Send::Enable { reset: false, .. }));
+ }
+
+ #[test]
+ fn a_reset_with_nothing_enabled_is_an_ordinary_enable() {
+ let mut ti = TextInput::default();
+ ti.enter();
+ assert_eq!(ti.plan(Some([0.0, 0.0, 1.0, 10.0]), 1.0, true), Send::Enable { rect: [0, 0, 1, 10], reset: false });
+ }
+}
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 7758eb3..4f558e1 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -27,6 +27,10 @@ use wayland_client::{
Connection, QueueHandle, Proxy,
};
+use wayland_protocols::wp::text_input::zv3::client::{
+ zwp_text_input_manager_v3::ZwpTextInputManagerV3,
+ zwp_text_input_v3::{self, ZwpTextInputV3},
+};
use wayland_protocols::wp::pointer_gestures::zv1::client::{
zwp_pointer_gesture_pinch_v1::{self, ZwpPointerGesturePinchV1},
zwp_pointer_gestures_v1::{self as zwp_pointer_gestures, ZwpPointerGesturesV1},
@@ -137,6 +141,12 @@ pub struct EngineState<A: Application> {
pub just_configured: bool,
pub pointer_gestures: Option<ZwpPointerGesturesV1>,
pub pinch_gesture: Option<ZwpPointerGesturePinchV1>,
+ /// `text-input-v3`, when the compositor offers it: the input method's
+ /// way in (see `backend::text_input`). The text input is the first
+ /// keyboard seat's.
+ pub text_input_manager: Option<ZwpTextInputManagerV3>,
+ pub text_input: Option<ZwpTextInputV3>,
+ pub text_input_state: crate::backend::text_input::TextInput,
/// The cce window-management toplevel handle, held for the window's
/// lifetime once [`Application::utility`] declared the mode.
pub cce_toplevel: Option<crate::protocol::cce_window_management_v1::zcce_toplevel_v1::ZcceToplevelV1>,
@@ -491,6 +501,40 @@ impl<A: Application> EngineState<A> {
} else {
self.damage_owed = false;
}
+ self.sync_text_input();
+ }
+
+ /// Bring the text input in step with the frame just built: enabled at
+ /// the editing widget's caret, disabled with nothing editing, reset for
+ /// a composition a widget dropped (`backend::text_input`).
+ fn sync_text_input(&mut self) {
+ use crate::backend::text_input::Send;
+ use zwp_text_input_v3::{ContentHint, ContentPurpose};
+ let reset = crate::ime::take_reset();
+ let Some(ti) = self.text_input.clone() else { return };
+ // Forced mode: the surface is the compositor's scale-1 space.
+ let surface_scale = crate::scale::forced_scale().unwrap_or(1.0);
+ match self.text_input_state.plan(crate::ime::caret(), surface_scale, reset) {
+ Send::Nothing => {}
+ Send::Enable { rect: [x, y, w, h], reset } => {
+ if reset {
+ ti.disable();
+ ti.commit();
+ }
+ ti.enable();
+ ti.set_content_type(ContentHint::None, ContentPurpose::Normal);
+ ti.set_cursor_rectangle(x, y, w, h);
+ ti.commit();
+ }
+ Send::Move { rect: [x, y, w, h] } => {
+ ti.set_cursor_rectangle(x, y, w, h);
+ ti.commit();
+ }
+ Send::Disable => {
+ ti.disable();
+ ti.commit();
+ }
+ }
}
}
@@ -876,6 +920,9 @@ impl<A: Application> SeatHandler for EngineState<A> {
if capability == Capability::Keyboard && self.keyboard.is_none() {
let keyboard = self.seat_state.get_keyboard(qh, &seat, None).unwrap();
self.keyboard = Some(keyboard);
+ if let (Some(m), None) = (&self.text_input_manager, &self.text_input) {
+ self.text_input = Some(m.get_text_input(&seat, qh, ()));
+ }
}
}
@@ -1350,6 +1397,64 @@ impl<A: Application> wayland_client::Dispatch<wl_callback::WlCallback, ()> for E
}
}
+impl<A: Application> wayland_client::Dispatch<ZwpTextInputManagerV3, ()> for EngineState<A> {
+ fn event(
+ _state: &mut Self,
+ _proxy: &ZwpTextInputManagerV3,
+ _event: <ZwpTextInputManagerV3 as wayland_client::Proxy>::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qh: &QueueHandle<Self>,
+ ) {
+ }
+}
+
+/// The input method's events (see `backend::text_input`): the focus, and
+/// the double-buffered composition, commit and deletion, applied on `done`.
+impl<A: Application> wayland_client::Dispatch<ZwpTextInputV3, ()> for EngineState<A> {
+ fn event(
+ state: &mut Self,
+ _proxy: &ZwpTextInputV3,
+ event: zwp_text_input_v3::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qh: &QueueHandle<Self>,
+ ) {
+ use crate::backend::text_input::Apply;
+ match event {
+ zwp_text_input_v3::Event::Enter { .. } => {
+ state.text_input_state.enter();
+ // A frame, so the sync after it enables for an editing widget.
+ state.redraw = true;
+ }
+ zwp_text_input_v3::Event::Leave { .. } => {
+ state.text_input_state.leave();
+ let (driver, t) = state.turn();
+ driver.preedit(t, None);
+ }
+ zwp_text_input_v3::Event::PreeditString { text, cursor_begin, cursor_end } => {
+ state.text_input_state.pending.set_preedit(text, cursor_begin, cursor_end);
+ }
+ zwp_text_input_v3::Event::CommitString { text } => {
+ state.text_input_state.pending.commit = text;
+ }
+ zwp_text_input_v3::Event::DeleteSurroundingText { before_length, after_length } => {
+ state.text_input_state.pending.delete = Some((before_length, after_length));
+ }
+ zwp_text_input_v3::Event::Done { .. } => {
+ for step in state.text_input_state.done().apply_order() {
+ let (driver, t) = state.turn();
+ match step {
+ Apply::Preedit(p) => driver.preedit(t, p),
+ Apply::Commit(text) => driver.commit_text(t, text),
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
impl<A: Application> wayland_client::Dispatch<ZwpPointerGesturesV1, ()> for EngineState<A> {
fn event(
_state: &mut Self,
@@ -1702,6 +1807,7 @@ fn run_session<'l, A: Application>(
let output_state = OutputState::new(&globals, &qh);
let pointer_gestures: Option<ZwpPointerGesturesV1> = globals.bind(&qh, 1..=3, ()).ok();
+ let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind(&qh, 1..=1, ()).ok();
let mut engine_state = EngineState {
data_device_manager: DataDeviceManagerState::bind(&globals, &qh).ok(),
@@ -1753,6 +1859,9 @@ fn run_session<'l, A: Application>(
qh: qh.clone(),
just_configured: false,
pointer_gestures,
+ text_input_manager,
+ text_input: None,
+ text_input_state: Default::default(),
pinch_gesture: None,
cce_toplevel: None,
pending_grid_patch: None,
diff --git a/src/ime.rs b/src/ime.rs
index dcfe8ac..8aa350e 100644
--- a/src/ime.rs
+++ b/src/ime.rs
@@ -26,10 +26,12 @@
//! mid-composition — asks the shell to cancel it in the input method too
//! ([`request_reset`], drained by the shell with [`take_reset`]).
//!
+//! The shells that feed it: the Wayland shell through `text-input-v3`
+//! (`backend::text_input`), the browser's through a hidden textarea, the
+//! AppKit shell as an `NSTextInputClient`.
+//!
//! Per thread, as the context menu is: a window's widgets, its shell and its
-//! frame are one thread's. The Wayland shell does not speak
-//! `text-input-v3` yet, so on Linux nothing sets a composition and this is
-//! inert.
+//! frame are one thread's.
use std::cell::RefCell;