GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(text_input): a field being edited enables text-input-v3
A widget open for typing calls cce_ui::text_input::claim from its paint;
once the frame's display list is built the runner enables the seat's
zwp_text_input_v3 with that rectangle, or disables it when no widget
claimed. The compositor uses the enable to raise the on-screen keyboard
after a touch.
TextBox, Spinbox, Slider's readout, ColorSelector, the params pane's code
rows and a focused DocEditor claim. An input method's commit_string is
delivered as one typed key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 18 +++
src/backend/mod.rs | 1 +
src/backend/text_input.rs | 232 ++++++++++++++++++++++++++++++++++
src/backend/window_runner.rs | 41 ++++++
src/lib.rs | 1 +
src/text_input.rs | 49 +++++++
src/widget/container/parameters_bg.rs | 4 +
src/widget/doc_editor/mod.rs | 7 +
src/widget/input/color_selector.rs | 5 +
src/widget/input/slider.rs | 5 +
src/widget/input/spinbox.rs | 4 +
src/widget/input/text_box.rs | 6 +
12 files changed, 373 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index a8c675a..e1607f1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1484,6 +1484,24 @@ button and selected rather than scrolled. `CCE_SCROLL_DEBUG=1` logs each
touch scroll (`[scroll] touch: …`); in a shadow, `ccectl touch down|motion|up`
drives it.
+### A field being edited says so (text-input-v3, since 2026-10-05)
+
+A widget open for typing calls `cce_ui::text_input::claim(x, y, w, h)` from
+its paint, every frame; once the display list is built the runner takes the
+frame's claim and `backend/text_input.rs` enables the seat's
+`zwp_text_input_v3` with that rectangle, or disables it when nobody claimed.
+The compositor raises the on-screen keyboard on an enable that follows a touch
+(`cce-compositor`'s `osk.rs`). A claim per frame, not an enable/disable pair,
+because a field leaves editing on many paths (Enter, Escape, a click
+elsewhere, focus loss, its page dropped) and a widget that stops painting has
+stopped claiming. `TextBox`, `Spinbox`, `Slider`'s readout, `ColorSelector`,
+the params pane's code rows and a focused `DocEditor` claim; an app that draws
+its own text (a `LineEdit`, a terminal, an editor) must call `claim` itself
+from `display_list` while it has a caret, or the board will not follow it.
+Keys still come over `wl_keyboard`; an input method's `commit_string` is
+delivered as one typed `Key::Character` (`EngineState::type_text`). Preedit
+and surrounding text are not implemented.
+
### A host may name the phase; a test may pin the settings (2026-09-30)
The phase a wheel event belongs to (`Finger`, `FingerEnd`, `Wheel`) is a
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index 1e1c764..d379fee 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -1,5 +1,6 @@
pub mod dnd;
pub mod menu_popup;
+pub mod text_input;
pub mod touch;
pub mod window_runner;
diff --git a/src/backend/text_input.rs b/src/backend/text_input.rs
new file mode 100644
index 0000000..71009d4
--- /dev/null
+++ b/src/backend/text_input.rs
@@ -0,0 +1,232 @@
+// text-input-v3 (zwp_text_input_v3): the seat's text input follows the
+// frame's claim (`crate::text_input`).
+//
+// While some widget claims a field, the text input is enabled and carries
+// the caret's rectangle; the first frame nobody claims, it is disabled. The
+// compositor only listens while this client holds keyboard focus (between
+// `enter` and `leave`), so the last frame's claim is kept and replayed on
+// `enter`, and `leave` disables: wlroots keeps a text input's enabled state
+// across a leave, and a stale "enabled" would turn the next enable into a
+// plain commit the compositor ignores.
+//
+// Nothing here types. Keys arrive over wl_keyboard as before (the on-screen
+// keyboard is a virtual keyboard); a `commit_string` from an input method is
+// delivered to the app as one typed key (`EngineState::type_text`).
+
+use smithay_client_toolkit::reexports::client::{Connection, Dispatch, QueueHandle};
+use wayland_protocols::wp::text_input::zv3::client::{
+ zwp_text_input_manager_v3::ZwpTextInputManagerV3,
+ zwp_text_input_v3::{self, ZwpTextInputV3},
+};
+
+use super::window_runner::{Application, EngineState};
+
+/// A request the sync wants sent, in order. `Commit` closes each batch.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Request {
+ Enable,
+ Disable,
+ CursorRectangle([i32; 4]),
+ Commit,
+}
+
+/// What the compositor has been told, and what the app wants. Pure, so the
+/// enter/leave/claim interplay is tested without a compositor.
+#[derive(Debug, Default)]
+pub struct Sync {
+ entered: bool,
+ enabled: bool,
+ sent_rect: Option<[i32; 4]>,
+ wanted: Option<[i32; 4]>,
+}
+
+impl Sync {
+ /// A frame was built; `claim` is what it claimed.
+ pub fn frame(&mut self, claim: Option<[f32; 4]>) -> Vec<Request> {
+ self.wanted = claim.map(|[x, y, w, h]| [x.round() as i32, y.round() as i32, w.round().max(1.0) as i32, h.round().max(1.0) as i32]);
+ self.apply()
+ }
+
+ pub fn enter(&mut self) -> Vec<Request> {
+ self.entered = true;
+ self.apply()
+ }
+
+ pub fn leave(&mut self) -> Vec<Request> {
+ let out = if self.enabled { vec![Request::Disable, Request::Commit] } else { Vec::new() };
+ self.entered = false;
+ self.enabled = false;
+ self.sent_rect = None;
+ out
+ }
+
+ fn apply(&mut self) -> Vec<Request> {
+ if !self.entered {
+ return Vec::new();
+ }
+ let mut out = Vec::new();
+ match self.wanted {
+ Some(rect) => {
+ if !self.enabled {
+ // `enable` resets the state, so the rectangle follows it.
+ out.push(Request::Enable);
+ self.enabled = true;
+ self.sent_rect = None;
+ }
+ if self.sent_rect != Some(rect) {
+ out.push(Request::CursorRectangle(rect));
+ self.sent_rect = Some(rect);
+ }
+ }
+ None => {
+ if self.enabled {
+ out.push(Request::Disable);
+ self.enabled = false;
+ self.sent_rect = None;
+ }
+ }
+ }
+ if !out.is_empty() {
+ out.push(Request::Commit);
+ }
+ out
+ }
+}
+
+/// The seat's text input and its sync.
+pub struct TextInput {
+ proxy: ZwpTextInputV3,
+ sync: Sync,
+ /// An input method's `commit_string`, held until its `done`.
+ pending_commit: Option<String>,
+}
+
+impl TextInput {
+ pub fn new<A: Application>(
+ manager: &ZwpTextInputManagerV3,
+ seat: &smithay_client_toolkit::reexports::client::protocol::wl_seat::WlSeat,
+ qh: &QueueHandle<EngineState<A>>,
+ ) -> Self {
+ TextInput { proxy: manager.get_text_input(seat, qh, ()), sync: Sync::default(), pending_commit: None }
+ }
+
+ pub fn frame(&mut self, claim: Option<[f32; 4]>) {
+ let requests = self.sync.frame(claim);
+ self.send(&requests);
+ }
+
+ fn send(&self, requests: &[Request]) {
+ for r in requests {
+ match *r {
+ Request::Enable => {
+ self.proxy.enable();
+ self.proxy.set_content_type(
+ zwp_text_input_v3::ContentHint::empty(),
+ zwp_text_input_v3::ContentPurpose::Normal,
+ );
+ }
+ Request::Disable => self.proxy.disable(),
+ Request::CursorRectangle([x, y, w, h]) => self.proxy.set_cursor_rectangle(x, y, w, h),
+ Request::Commit => self.proxy.commit(),
+ }
+ }
+ }
+}
+
+impl Drop for TextInput {
+ fn drop(&mut self) {
+ self.proxy.destroy();
+ }
+}
+
+impl<A: Application> Dispatch<ZwpTextInputManagerV3, ()> for EngineState<A> {
+ fn event(
+ _state: &mut Self,
+ _proxy: &ZwpTextInputManagerV3,
+ _event: <ZwpTextInputManagerV3 as smithay_client_toolkit::reexports::client::Proxy>::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qh: &QueueHandle<Self>,
+ ) {
+ }
+}
+
+impl<A: Application> Dispatch<ZwpTextInputV3, ()> for EngineState<A> {
+ fn event(
+ state: &mut Self,
+ _proxy: &ZwpTextInputV3,
+ event: zwp_text_input_v3::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qh: &QueueHandle<Self>,
+ ) {
+ let Some(ti) = state.text_input.as_mut() else { return };
+ match event {
+ zwp_text_input_v3::Event::Enter { .. } => {
+ let requests = ti.sync.enter();
+ ti.send(&requests);
+ }
+ zwp_text_input_v3::Event::Leave { .. } => {
+ let requests = ti.sync.leave();
+ ti.send(&requests);
+ ti.pending_commit = None;
+ }
+ zwp_text_input_v3::Event::CommitString { text } => {
+ ti.pending_commit = text;
+ }
+ zwp_text_input_v3::Event::Done { .. } => {
+ if let Some(text) = ti.pending_commit.take().filter(|t| !t.is_empty()) {
+ state.type_text(text);
+ }
+ }
+ // No preedit display and no surrounding text: an input method
+ // composing in place is not supported, only its committed text.
+ _ => {}
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::Request::*;
+ use super::*;
+
+ const R: [f32; 4] = [10.0, 20.0, 100.0, 30.0];
+ const RI: [i32; 4] = [10, 20, 100, 30];
+
+ #[test]
+ fn nothing_is_sent_before_enter() {
+ let mut s = Sync::default();
+ assert!(s.frame(Some(R)).is_empty());
+ assert_eq!(s.enter(), vec![Enable, CursorRectangle(RI), Commit], "the claim waits for enter");
+ }
+
+ #[test]
+ fn a_claim_enables_once_and_follows_the_caret() {
+ let mut s = Sync::default();
+ s.enter();
+ assert_eq!(s.frame(Some(R)), vec![Enable, CursorRectangle(RI), Commit]);
+ assert!(s.frame(Some(R)).is_empty(), "an unchanged frame sends nothing");
+ let moved = [12.0, 20.0, 100.0, 30.0];
+ assert_eq!(s.frame(Some(moved)), vec![CursorRectangle([12, 20, 100, 30]), Commit]);
+ assert_eq!(s.frame(None), vec![Disable, Commit]);
+ assert!(s.frame(None).is_empty());
+ }
+
+ #[test]
+ fn leave_disables_and_enter_restores() {
+ let mut s = Sync::default();
+ s.enter();
+ s.frame(Some(R));
+ assert_eq!(s.leave(), vec![Disable, Commit]);
+ assert!(s.frame(Some(R)).is_empty(), "unfocused: the compositor is not listening");
+ assert_eq!(s.enter(), vec![Enable, CursorRectangle(RI), Commit]);
+ }
+
+ #[test]
+ fn leave_without_a_field_sends_nothing() {
+ let mut s = Sync::default();
+ s.enter();
+ assert!(s.leave().is_empty());
+ }
+}
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 0d72b4b..d2b83b4 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -4215,6 +4215,9 @@ pub struct EngineState<A: Application> {
pub just_configured: bool,
pub pointer_gestures: Option<ZwpPointerGesturesV1>,
pub pinch_gesture: Option<ZwpPointerGesturePinchV1>,
+ /// text-input-v3: enabled while a widget is editing (`backend::text_input`).
+ pub text_input_manager: Option<wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3>,
+ pub text_input: Option<super::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>,
@@ -4576,6 +4579,12 @@ impl<A: Application> EngineState<A> {
let dl = self.inner.as_mut().unwrap()
.display_list(LogicalSize::new(logical_w, logical_h), scale_factor)
.unwrap_or_else(|| crate::scene::paint::PaintCtx::new().finish());
+ // The paint just built says whether a field is editing
+ // (`crate::text_input`); the compositor hears it with this frame.
+ let text_claim = crate::text_input::take();
+ if let Some(ti) = self.text_input.as_mut() {
+ ti.frame(text_claim);
+ }
// Taken with the display list it describes. A frame that took the
// app's damage and then was not presented owes those pixels, so the
// next one that is presented repaints everything.
@@ -5056,6 +5065,10 @@ 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);
+ // Text-input focus follows keyboard focus, so it lives with it.
+ if let Some(ref manager) = self.text_input_manager {
+ self.text_input = Some(super::text_input::TextInput::new(manager, &seat, qh));
+ }
}
if capability == Capability::Touch && self.touch.is_none() {
self.touch = self.seat_state.get_touch(qh, &seat).ok();
@@ -5075,6 +5088,7 @@ impl<A: Application> SeatHandler for EngineState<A> {
}
if capability == Capability::Keyboard {
self.keyboard = None;
+ self.text_input = None;
}
if capability == Capability::Touch {
self.touch_lost();
@@ -5593,6 +5607,30 @@ impl<A: Application> EngineState<A> {
taken
}
+ /// Text an input method committed (`backend::text_input`), delivered as
+ /// one typed key: a press and a release of `Key::Character(text)`, the
+ /// shape a text box already inserts from.
+ pub(crate) fn type_text(&mut self, text: String) {
+ for state in [ElementState::Pressed, ElementState::Released] {
+ let event = KeyEvent {
+ state,
+ logical_key: Key::Character(text.clone()),
+ text: Some(text.clone()),
+ repeat: false,
+ ctrl: false,
+ shift: false,
+ alt: false,
+ };
+ let mut rebuild = false;
+ if let Some(msg) = self.inner.as_mut().unwrap().handle_key_input(&event, &mut rebuild) {
+ self.inner.as_mut().unwrap().update(msg, &mut rebuild, &mut self.exit);
+ }
+ if rebuild {
+ self.redraw = true;
+ }
+ }
+ }
+
fn handle_key(&mut self, event: smithay_client_toolkit::seat::keyboard::KeyEvent, state: ElementState) {
let logical_key = match event.keysym {
xkeysym::Keysym::Escape => Key::Named(NamedKey::Escape),
@@ -6248,6 +6286,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 = globals.bind(&qh, 1..=1, ()).ok();
let mut engine_state = EngineState {
data_device_manager: DataDeviceManagerState::bind(&globals, &qh).ok(),
@@ -6308,6 +6347,8 @@ fn run_session<'l, A: Application>(
qh: qh.clone(),
just_configured: false,
pointer_gestures,
+ text_input_manager,
+ text_input: None,
pinch_gesture: None,
cce_toplevel: None,
pending_grid_patch: None,
diff --git a/src/lib.rs b/src/lib.rs
index 6c60d1a..cead6a9 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -18,6 +18,7 @@ pub mod icon;
pub mod ipc;
pub mod mcp;
pub mod motion;
+pub mod text_input;
pub mod vk;
pub mod colors {
diff --git a/src/text_input.rs b/src/text_input.rs
new file mode 100644
index 0000000..f283c9b
--- /dev/null
+++ b/src/text_input.rs
@@ -0,0 +1,49 @@
+//! "A text field is being edited": the announcement behind text-input-v3.
+//!
+//! cce-ui types through `wl_keyboard`; the compositor still needs to know when
+//! a field is open for typing, because that is what raises the on-screen
+//! keyboard after a touch (cce-compositor's `osk.rs`) and what activates an
+//! input method. A widget says so by calling [`claim`] from its paint, every
+//! frame it is editing; the runner reads the frame's claim once the display
+//! list is built and enables the seat's text input, or disables it when no
+//! widget claimed (`backend::text_input`).
+//!
+//! A claim per frame rather than an enable/disable pair is deliberate: a text
+//! box leaves editing by Enter, Escape, a click elsewhere, a focus change or
+//! being dropped with its page, and a pair would have to be closed on every
+//! one of those paths. A widget that stops painting has stopped claiming.
+//!
+//! `TextBox` claims on its own. An app drawing its own text surface (an
+//! editor, a terminal) calls `claim` from its `display_list` while it has a
+//! caret.
+
+use std::cell::Cell;
+
+thread_local! {
+ static CLAIM: Cell<Option<[f32; 4]>> = const { Cell::new(None) };
+}
+
+/// A field is editing this frame, its caret (or the field, when the caret is
+/// not known) at `x, y, w, h` in logical surface coordinates. The last claim
+/// of a frame wins.
+pub fn claim(x: f32, y: f32, w: f32, h: f32) {
+ CLAIM.with(|c| c.set(Some([x, y, w, h])));
+}
+
+/// The frame's claim, clearing it for the next frame.
+pub(crate) fn take() -> Option<[f32; 4]> {
+ CLAIM.with(|c| c.take())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_claim_lasts_one_frame() {
+ let _ = take();
+ claim(1.0, 2.0, 3.0, 4.0);
+ assert_eq!(take(), Some([1.0, 2.0, 3.0, 4.0]));
+ assert_eq!(take(), None, "a frame nobody claimed disables the text input");
+ }
+}
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 4b87517..ed9738f 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -2137,6 +2137,10 @@ impl Paint for ParametersBg {
/// runs): the flat subset plus the scrollbar, kept for direct callers only. The background
/// plate stays out — see [`color`](Paint::color).
fn paint(&self, _rect: Rect, ctx: &mut PaintCtx) {
+ // A code row open for typing (its fields claim for themselves).
+ if self.code_editing() {
+ crate::text_input::claim(self.rect.x, self.rect.y, self.rect.width, self.rect.height);
+ }
for (qx, qy, qw, qh, qc) in self.plain_quads() {
ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
}
diff --git a/src/widget/doc_editor/mod.rs b/src/widget/doc_editor/mod.rs
index 6b4a992..c9ebfd5 100644
--- a/src/widget/doc_editor/mod.rs
+++ b/src/widget/doc_editor/mod.rs
@@ -887,6 +887,12 @@ impl DocEditor {
let sel = self.buf.selection();
let caret = self.buf.caret;
let th = self.theme.clone();
+ // Focused is typing: the on-screen keyboard follows
+ // (`crate::text_input`). The viewport stands in until the caret's
+ // line is drawn below, and stays when it is scrolled out of view.
+ if focused {
+ crate::text_input::claim(rect.x, rect.y, rect.width, rect.height);
+ }
let first = self.line_at_y(self.scroll - self.pad);
pc.clip(rect, |pc| {
let mut i = first;
@@ -949,6 +955,7 @@ impl DocEditor {
let h = l.row_h * 0.8;
let y = top + row as f32 * l.row_h + (l.row_h - h) / 2.0;
pc.quad(Rect { x: ox + x - 0.5, y, width: 2.0, height: h }, th.caret);
+ crate::text_input::claim(ox + x - 0.5, y, 2.0, h);
}
i += 1;
}
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index bc063f3..3df983c 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -245,6 +245,11 @@ impl Paint for ColorSelector {
let visual_h = rect.height;
let pick_x = rect.x + rect.width * 0.65;
let pick_w = rect.width * 0.35;
+ // Typing a hex value: the on-screen keyboard follows
+ // (`crate::text_input`).
+ if self.editing {
+ crate::text_input::claim(rect.x, rect.y, pick_x - rect.x, visual_h);
+ }
// The text field has NO face of its own — a frame over the host plate,
// like a relief TextBox well (transparent fill, the outline defines
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 4c7d38a..7b88e6f 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -356,6 +356,11 @@ impl Paint for Slider {
fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
let g = self.geom(rect);
+ // Typing into the readout: the on-screen keyboard follows
+ // (`crate::text_input`).
+ if self.editing {
+ crate::text_input::claim(g.x, g.y, g.w, g.h);
+ }
let radius = crate::layout::slider_corner_radius();
let rounded = radius > 0.0;
let rc = (rounded, rounded, rounded, rounded);
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 77ced27..1950016 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -357,6 +357,10 @@ impl Paint for Spinbox {
fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
let g = self.geom(rect);
+ // Typing a value: the on-screen keyboard follows (`crate::text_input`).
+ if self.editing {
+ crate::text_input::claim(g.x, g.y, g.w, g.h);
+ }
let radius = crate::layout::spinbox_corner_radius();
let rounded = radius > 0.0;
let display_bg = if self.editing { [0.06, 0.10, 0.18, 1.0] } else { colors::spinbox_display() };
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 485f71a..f0d8263 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1513,6 +1513,12 @@ impl Paint for TextBox {
let radius = crate::layout::textbox_corner_radius();
let border_w = self.border_width();
+ // Open for typing: say so to the compositor this frame (the
+ // on-screen keyboard follows it). The field stands in for the caret.
+ if self.editing && !self.disabled {
+ crate::text_input::claim(self.rect.x, self.rect.y + top, self.rect.width, visual_h);
+ }
+
// Keep the model's cached rect and the paint rect consistent: paint receives the
// content rect derived from the same base the cache holds, so the bodies below read
// `self.rect` (the legacy `self.base`) exactly as legacy did. `rect` is used only to