GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
fix(text_input): a tapped open field raises the board; cached and hosted fields claim
Three gaps between the text-input claim and the apps that use it:
- A field already open when tapped sent nothing, so the compositor's
on-screen keyboard never followed the tap: a focused terminal claims
from the moment it maps, and a text box stays editing after the board
is dismissed. The driver now marks a pointer or touch press
(ime::note_press) and the next plan that still has a caret commits it
again, unchanged. A press that ends the editing disables instead, so a
tap away from a field never flashes the board; a mouse click
re-commits too, and the compositor ignores it (no finger armed it).
- An app that paints its widgets only in a rebuild_layout and replays
the cached frame (cce-system-interface, cce-files) claimed on rebuild
frames alone, and the first replayed frame disabled the field.
text_input::capture reports what a painting claimed, for the app to
claim again every frame.
- ParametersBg draws its hosted text boxes, spinboxes, sliders, colours
and vectors from its own views, never through their paint, so none of
them claimed in the designer's params pane. The pane claims for the
row being typed into, from paint_ui as well as paint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 22 ++++++++++++++
src/backend/driver.rs | 10 +++++++
src/backend/text_input.rs | 55 +++++++++++++++++++++++++----------
src/backend/window_runner.rs | 3 +-
src/ime.rs | 42 ++++++++++++++++++++++++++
src/text_input.rs | 35 ++++++++++++++++++++++
src/widget/container/parameters_bg.rs | 44 ++++++++++++++++++++++++----
7 files changed, 190 insertions(+), 21 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 9bb6456..8aa0fc5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1915,6 +1915,28 @@ and `leave` disables an enabled text input: wlroots keeps the enabled state
across a leave, and a stale "enabled" turns the next enable into a plain
commit the compositor ignores.
+Three things learned taking it to the apps (2026-10-05):
+
+- **A press re-announces an open field.** The compositor reacts only to an
+ enable or a commit right after a touch, and a field already open (a focused
+ terminal claims from the moment it maps; a text box still editing) sends
+ neither when tapped again. So the driver marks a pointer or touch press
+ (`ime::note_press`) and the next plan that still has a caret commits once
+ more, unchanged (`TextInput::plan`'s `pressed`); a press that ends the
+ editing disables instead, so tapping away never flashes the board. A mouse
+ click re-commits too and the compositor ignores it (no finger armed it).
+- **An app that replays cached geometry must replay the claim.** A host that
+ paints its widgets only in a `rebuild_layout` (cce-system-interface,
+ cce-files) claims on rebuild frames alone, and the first replayed frame
+ disables the field: in a shadow the board was already gone two seconds
+ after the tap. Run the rebuild under `text_input::capture` and claim what
+ it returns on every frame.
+- **A widget drawn from its host's aggregates never claims.** `ParametersBg`
+ paints its hosted text boxes, spinboxes, sliders, colours and vectors from
+ its own views, not through their `paint`, so none of their claims ran in
+ the designer; the pane claims for the row being typed into
+ (`claim_typing`), from `paint_ui` as well as `paint`.
+
### 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/driver.rs b/src/backend/driver.rs
index bda611f..982376f 100644
--- a/src/backend/driver.rs
+++ b/src/backend/driver.rs
@@ -375,6 +375,11 @@ impl Driver {
if btn == MouseButton::Left {
close_popovers_missed_by(t.app, lx, ly);
}
+ // A field still open after the press is announced again, so the
+ // frame must be built (`ime::note_press`).
+ if crate::ime::note_press() {
+ *t.redraw = true;
+ }
let mut rebuild = false;
let msg = t.app.handle_mouse_input(btn, ElementState::Pressed, pos, &mut rebuild);
@@ -448,6 +453,11 @@ impl Driver {
// Outside-press close for open popovers, as the pointer's
// press does before the app's own dispatch.
close_popovers_missed_by(t.app, x, y);
+ // A tapped field that stays open is announced again,
+ // so the on-screen keyboard can follow the tap.
+ if crate::ime::note_press() {
+ rebuild = true;
+ }
let pos = LogicalPosition::new(x, y);
msg = t.app.handle_mouse_input(MouseButton::Left, ElementState::Pressed, pos, &mut rebuild);
}
diff --git a/src/backend/text_input.rs b/src/backend/text_input.rs
index f6d3b6f..aee6f07 100644
--- a/src/backend/text_input.rs
+++ b/src/backend/text_input.rs
@@ -27,6 +27,16 @@
//! "enabled" turns the next enable into a plain commit the compositor
//! ignores.
//!
+//! - A press re-announces a field that survives it (`ime::note_press`):
+//! the next plan that still has a caret commits it again, unchanged, as a
+//! `Move` to the same rectangle. The compositor raises its on-screen
+//! keyboard on an enable or commit that follows a touch, and a field that
+//! was already open (a focused terminal, a text box still editing after the
+//! board was dismissed) would otherwise send nothing when tapped again. A
+//! press that ends the editing disables instead, so tapping away never
+//! flashes the board; a mouse click re-commits too, and the compositor
+//! ignores it, since no finger armed the board.
+//!
//! The pure part — what a batch does, and what state to send — is
//! [`Batch::apply_order`] and [`TextInput::plan`], tested with no
//! compositor.
@@ -103,15 +113,16 @@ 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 {
+ /// cancelled, and whether a press landed since the last plan (an open
+ /// field is then committed again). Records the new state as sent.
+ pub fn plan(&mut self, caret: Option<[f32; 4]>, surface_scale: f32, reset: bool, pressed: 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(rect)) if self.sent_rect != Some(rect) || pressed => Send::Move { rect },
(true, Some(_)) => Send::Nothing,
(true, None) if self.enabled => Send::Disable,
_ => Send::Nothing,
@@ -197,23 +208,23 @@ mod tests {
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);
+ assert_eq!(ti.plan(caret, 1.0, false, 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);
+ assert_eq!(ti.plan(caret, 1.0, false, false), Send::Enable { rect: [10, 20, 2, 16], reset: false });
+ assert_eq!(ti.plan(caret, 1.0, false, 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] });
+ assert_eq!(ti.plan(Some([30.0, 20.0, 1.5, 16.0]), 2.0, false, 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 });
+ assert_eq!(ti.plan(Some([30.0, 20.0, 1.5, 16.0]), 2.0, true, false), 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.plan(None, 1.0, false, false), Send::Disable);
+ assert_eq!(ti.plan(None, 1.0, false, false), Send::Nothing);
assert_eq!(ti.commits, 5, "enable, move, disable + enable, disable");
// The focus leaves; editing again enables nothing until it is back.
assert!(!ti.leave(), "nothing enabled, nothing owed");
- assert_eq!(ti.plan(caret, 1.0, false), Send::Nothing);
+ assert_eq!(ti.plan(caret, 1.0, false, false), Send::Nothing);
ti.enter();
- assert!(matches!(ti.plan(caret, 1.0, false), Send::Enable { reset: false, .. }));
+ assert!(matches!(ti.plan(caret, 1.0, false, false), Send::Enable { reset: false, .. }));
}
#[test]
@@ -221,17 +232,31 @@ mod tests {
let mut ti = TextInput::default();
let caret = Some([10.0, 20.0, 1.5, 16.0]);
ti.enter();
- ti.plan(caret, 1.0, false);
+ ti.plan(caret, 1.0, false, false);
assert!(ti.leave(), "wlroots keeps the enabled state across a leave");
assert_eq!(ti.commits, 2, "the enable, then the disable");
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, false), Send::Enable { rect: [10, 20, 2, 16], reset: false });
+ }
+
+ #[test]
+ fn a_press_on_an_open_field_commits_it_again() {
+ let mut ti = TextInput::default();
+ let caret = Some([10.0, 20.0, 1.5, 16.0]);
+ ti.enter();
+ ti.plan(caret, 1.0, false, false);
+ assert_eq!(ti.plan(caret, 1.0, false, true), Send::Move { rect: [10, 20, 2, 16] }, "unchanged, announced again");
+ assert_eq!(ti.plan(caret, 1.0, false, false), Send::Nothing);
+ // A press that ends the editing only disables.
+ assert_eq!(ti.plan(None, 1.0, false, true), Send::Disable);
+ // A press with nothing editing says nothing.
+ assert_eq!(ti.plan(None, 1.0, false, true), Send::Nothing);
}
#[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 });
+ assert_eq!(ti.plan(Some([0.0, 0.0, 1.0, 10.0]), 1.0, true, false), Send::Enable { rect: [0, 0, 1, 10], reset: false });
}
}
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 72df970..c8b4c64 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -536,7 +536,8 @@ impl<A: Application> EngineState<A> {
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) {
+ let pressed = crate::ime::take_press();
+ match self.text_input_state.plan(crate::ime::caret(), surface_scale, reset, pressed) {
Send::Nothing => {}
Send::Enable { rect: [x, y, w, h], reset } => {
if reset {
diff --git a/src/ime.rs b/src/ime.rs
index 8aa350e..c4e9c48 100644
--- a/src/ime.rs
+++ b/src/ime.rs
@@ -73,6 +73,8 @@ struct State {
caret: Option<[f32; 4]>,
reported: Option<[f32; 4]>,
reset: bool,
+ /// A press landed since the shell last asked ([`note_press`]).
+ pressed: bool,
}
thread_local! {
@@ -134,6 +136,32 @@ pub fn request_reset() {
STATE.with(|s| s.borrow_mut().reset = true);
}
+/// What this frame has reported so far, replaced by `caret`. For
+/// `text_input::capture`, which reads what one painting reported.
+pub(crate) fn swap_reported(caret: Option<[f32; 4]>) -> Option<[f32; 4]> {
+ STATE.with(|s| std::mem::replace(&mut s.borrow_mut().reported, caret))
+}
+
+/// A pointer or touch press reached the window. A field that is still
+/// editing in the next frame is announced to the shell again
+/// ([`take_press`]): the compositor raises its on-screen keyboard on an
+/// announcement that follows a touch, and a field that was already open —
+/// a focused terminal, a text box still editing — would otherwise say
+/// nothing new when tapped. True when a field is editing, so the caller
+/// builds that frame.
+pub fn note_press() -> bool {
+ STATE.with(|s| {
+ let mut s = s.borrow_mut();
+ s.pressed = true;
+ s.caret.is_some()
+ })
+}
+
+/// Whether a press landed since the last call. A shell's.
+pub fn take_press() -> bool {
+ STATE.with(|s| std::mem::replace(&mut s.borrow_mut().pressed, false))
+}
+
/// Whether a reset was asked for since the last call. A shell's.
pub fn take_reset() -> bool {
STATE.with(|s| std::mem::replace(&mut s.borrow_mut().reset, false))
@@ -179,6 +207,20 @@ mod tests {
assert_eq!(caret(), None, "a frame with nothing editing");
}
+ #[test]
+ fn a_press_wants_a_frame_only_while_a_field_is_editing() {
+ begin_frame();
+ end_frame();
+ assert!(!note_press(), "nothing editing: no frame owed");
+ assert!(take_press(), "but the press is still noted");
+ assert!(!take_press(), "once");
+ begin_frame();
+ report_caret(1.0, 2.0, 3.0, 4.0);
+ end_frame();
+ assert!(note_press());
+ assert!(take_press());
+ }
+
#[test]
fn a_reset_clears_the_composition_and_is_taken_once() {
set_preedit(Some(Preedit::new("ka", None)));
diff --git a/src/text_input.rs b/src/text_input.rs
index b8b02f1..240960d 100644
--- a/src/text_input.rs
+++ b/src/text_input.rs
@@ -20,6 +20,12 @@
//! pane's code rows and a focused `DocEditor` claim on their own. An app
//! drawing its own text (a `LineEdit`, a terminal, an editor) claims from its
//! `display_list` while it has a caret.
+//!
+//! An app that paints its widgets only on some frames and replays the cached
+//! result on the rest (a `rebuild_layout` flattening the tree) would claim on
+//! the rebuilds alone, and every replayed frame would read as the field
+//! closing. It runs the painting under [`capture`] and claims the captured
+//! rectangle again on every frame.
/// A field is editing this frame, its caret (or the field, when the caret is
/// not known) at `x, y, w, h` in the window's logical px. The last claim of a
@@ -28,6 +34,17 @@ pub fn claim(x: f32, y: f32, w: f32, h: f32) {
crate::ime::report_caret(x, y, w, h);
}
+/// Run `paint` and return what it claimed, alongside its result. A claim
+/// made earlier this frame stands if `paint` makes none, so wrapping a
+/// painting changes nothing about the frame's claim — it only reports it.
+pub fn capture<R>(paint: impl FnOnce() -> R) -> (R, Option<[f32; 4]>) {
+ let before = crate::ime::swap_reported(None);
+ let out = paint();
+ let made = crate::ime::swap_reported(None);
+ crate::ime::swap_reported(made.or(before));
+ (out, made)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -42,4 +59,22 @@ mod tests {
crate::ime::end_frame();
assert_eq!(crate::ime::caret(), None, "a frame nobody claimed disables the text input");
}
+
+ #[test]
+ fn capture_reports_a_painting_claim_and_keeps_the_frame_whole() {
+ crate::ime::begin_frame();
+ claim(1.0, 1.0, 1.0, 1.0);
+ let ((), made) = capture(|| {});
+ assert_eq!(made, None, "a painting that claims nothing reports nothing");
+ crate::ime::end_frame();
+ assert_eq!(crate::ime::caret(), Some([1.0, 1.0, 1.0, 1.0]), "and the earlier claim stands");
+ crate::ime::begin_frame();
+ let (n, made) = capture(|| {
+ claim(5.0, 6.0, 7.0, 8.0);
+ 3
+ });
+ assert_eq!((n, made), (3, Some([5.0, 6.0, 7.0, 8.0])));
+ crate::ime::end_frame();
+ assert_eq!(crate::ime::caret(), Some([5.0, 6.0, 7.0, 8.0]), "the frame keeps what was captured");
+ }
}
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 3836ed5..8b0f325 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -232,6 +232,43 @@ impl ParametersBg {
self.focused_param.is_some() && self.code_editor.is_some()
}
+ /// The row open for typing, if any: a code row, or a hosted text box,
+ /// spinbox, slider readout, colour or vector field that is editing. (A
+ /// choice row holds `focused_param` while its list is open; that is not
+ /// typing.)
+ fn typing_row(&self) -> Option<usize> {
+ let i = self.focused_param?;
+ if self.code_editor.is_some() {
+ return Some(i);
+ }
+ fn at<T>(v: &[Option<T>], i: usize) -> Option<&T> {
+ v.get(i).and_then(Option::as_ref)
+ }
+ let typing = at(&self.texts, i).is_some_and(|t| t.editing)
+ || at(&self.spinboxes, i).is_some_and(|t| t.editing)
+ || at(&self.sliders, i).is_some_and(|t| t.editing)
+ || at(&self.colors, i).is_some_and(|t| t.editing)
+ || at(&self.float3s, i).is_some_and(|t| t.editing_idx().is_some());
+ typing.then_some(i)
+ }
+
+ /// Say a field is open for typing (`crate::text_input`): at its row, or
+ /// the pane when the row is not laid out. The hosted fields are drawn
+ /// from this pane's aggregates, never through their own paint, so their
+ /// own claims never run — the pane makes it for them.
+ fn claim_typing(&self, ctx: &PaintCtx) {
+ if let Some(i) = self.typing_row() {
+ let (x, y, w, h) = self
+ .get_param_rects()
+ .get(i)
+ .copied()
+ .filter(|r| r.3 > 0.0)
+ .unwrap_or((self.rect.x, self.rect.y, self.rect.width, self.rect.height));
+ let (ox, oy) = ctx.offset();
+ crate::text_input::claim(x + ox, y + oy, w, h);
+ }
+ }
+
/// The clipboard, selection and history actions over the code editor:
/// what the runner's undo / redo chords and the context menu's rows
/// reach through [`Input::context_action`], and what the editor's own
@@ -2085,6 +2122,7 @@ impl Paint for ParametersBg {
if !self.visible {
return;
}
+ self.claim_typing(ctx);
self.paint_row_floors(ctx);
for (qx, qy, qw, qh, qr, qc, corners) in self.rounded_quads(ui) {
ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, qr, corners, qc);
@@ -2137,11 +2175,7 @@ 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() {
- let (ox, oy) = ctx.offset();
- crate::text_input::claim(self.rect.x + ox, self.rect.y + oy, self.rect.width, self.rect.height);
- }
+ self.claim_typing(ctx);
for (qx, qy, qw, qh, qc) in self.plain_quads() {
ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
}