From 2af281520bdb30d950c14beec582fc7fe4fcaadd Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Mon, 21 Sep 2026 12:45:09 +0200 Subject: [PATCH] External text input for fields ltk does not own; `TextEdit::color` and `TextEdit::on_focus` Two additions driven by apps that host another UI inside an ltk surface: trust, whose pages have their own form fields, and rustdroid, which projects Android views into ltk widgets. External text input. ltk only ever enabled `zwp_text_input_v3` for its own `TextEdit`s, so nothing told the compositor that an editable inside an embedded web view had the focus: the on-screen keyboard never came up inside a page, which makes a browser unusable on a phone without a hardware keyboard. Three hooks on `App` close that. `external_text_input() -> Option` asks for the input method on behalf of such a field: while it returns `Some( serial )` and no ltk text widget holds the focus, the runtime keeps a text-input enabled, and the compositor raises its keyboard. `on_external_text_commit( text )` and `on_external_text_delete( before, after )` receive what the input method sends, the latter in UTF-8 bytes as the protocol counts them. Keys such as Backspace and Enter keep arriving as key events, which hosts already forward. The request is a serial rather than a `bool` so that it can be renewed. A compositor that folds its keyboard while the field keeps the focus, which forge does on back, has nothing to observe when the user taps that same field again, because the text-input never stopped being active; a change of serial makes the runtime send `enable` again through `reenable_text_input`, the same signal ltk now sends for its own fields. The host chooses what bumps it; trust counts taps on the page, so dragging to scroll does not bring a folded keyboard back. `AppData::sync_external_text_input`, in `text_editing/ime.rs`, is polled after every batch of `update`s next to `sync_fullscreen`. An ltk text widget always wins: while one has the focus the function only clears its record, and `set_focus` manages the text-input as before. When that widget loses the focus `set_focus` destroys the text-input, and the next poll finds the request still standing with no text-input and enables a new one, so the two owners hand it back and forth without either knowing about the other. The `zwp_text_input_v3` dispatch routes `commit_string` and `delete_surrounding_text` to the app only when the external request is held and no ltk text field is focused; that second check covers the one iteration between a widget taking the focus and the next poll. The empty-string filter on `commit_string` is folded into the `Option` it already lived in. `TextEdit::color( c )`. The colour of a field's value always came from the theme. A field placed over a surface the theme knows nothing about, an Android `EditText` with its own text colour on its own background, could end up unreadable. `color` sets the value's colour in both draw paths, single-line and multiline; the placeholder keeps the theme's, and a field without it draws exactly as before. `TextEdit::on_focus( msg )`. `App::on_text_input_focused( bool )` says that some text field is focused, not which. An app that mirrors focus into another model needs to know which: rustdroid keeps Android's single focused view in step, and could only learn the field from its first `on_change`, so `View.isFocused()` stayed false until the user typed. A `WidgetId` cannot carry that, being a `&'static str` while such handles are created at run time. A per-widget message can, since the app builds it and puts in it whatever it needs. It rides the `WidgetHandlers::TextEdit` snapshot, and `set_focus` pushes it when the focus actually moves to the field, from a press, Tab or `App::take_focus_request`, and not again for a press on the field that already holds it. Every other match on that handler variant uses `..`, so the new field touches its definition, its clone and the snapshot in `element.rs`. Documented in the `App` rustdoc, the trait surface list of `docs/architecture.md`, the `text_edit` entry of `docs/widgets.md` and the changelog. Neither path has a test: both need a live Wayland connection and a compositor to exercise. --- CHANGELOG.md | 2 ++ docs/architecture.md | 1 + docs/widgets.md | 2 ++ src/app.rs | 11 +++++++++++ src/event_loop/app_data.rs | 2 ++ src/event_loop/focus.rs | 12 ++++++++++++ src/event_loop/handlers.rs | 18 +++++++++++------- src/event_loop/run.rs | 2 ++ src/event_loop/text_editing/ime.rs | 23 +++++++++++++++++++++++ src/widget/element.rs | 1 + src/widget/handlers.rs | 4 +++- src/widget/text_edit/draw.rs | 4 ++-- src/widget/text_edit/mod.rs | 24 +++++++++++++++++++++++- 13 files changed, 95 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e64495..49aa1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Added +- **`TextEdit::color( c )` / `TextEdit::on_focus( msg )`** — `color` sets the colour of the value, which always came from the theme; the placeholder keeps the theme's. `on_focus` emits a message when the field takes the keyboard focus (press, Tab, `take_focus_request`), once per change and not for a press on the field that already holds it. `App::on_text_input_focused( bool )` only says that some text field is focused; an app that mirrors focus into another model (rustdroid keeps Android's focused view in step) needs to know which, and a per-widget message carries that without a `WidgetId`, which is a `&'static str` and cannot name a dynamic handle. The message rides the `WidgetHandlers::TextEdit` snapshot and is pushed from `set_focus`. +- **External text input: `App::external_text_input()`, `on_external_text_commit`, `on_external_text_delete`** — lets an app hold the input method for a text field ltk does not own: an editable inside an embedded web view, a guest toolkit's field. While `external_text_input` returns `Some( serial )` and no ltk text widget has the focus, the runtime keeps a `zwp_text_input_v3` enabled, so the compositor raises its on-screen keyboard, and routes `commit_string` / `delete_surrounding_text` to the two new hooks instead of to a widget; a change of `serial` renews the `enable`, which is how a host asks for a keyboard the user folded to come back. Polled after every batch of `update`s through `AppData::sync_external_text_input`; an ltk text widget that takes the focus wins and hands the text-input back when it loses it. Keys such as Backspace and Enter keep arriving as key events. Default `None`. - **`App::on_back()` / `BackResponse`** — the app's answer to a back request (the shell's back button or gesture, delivered as `XF86Back`, or a mouse's `BTN_SIDE` / `BTN_BACK`). Back peels one layer per request: transient UI the runtime owns (xdg-popups, context menu, text selection, a dialog's `on_escape`), then text-input focus, then `on_back`, then `on_key_with_modifiers( XF86_Back )` for apps that bound the key themselves, then `back_at_root`. `BackResponse::Message` carries the message that undoes the top-most piece of navigation state, `Consumed` is for hosts whose back stack lives elsewhere (an embedded Android runtime), `Unhandled` means the root. Default `Unhandled`. - **`App::back_at_root()` / `BackAtRoot`** — what back does with nothing left to go back to: `Minimize` (default; `xdg_toplevel.set_minimized`, the app and its state stay alive), `Close` (through `on_close_requested`) or `Ignore`. Only `ShellMode::Window` surfaces react; layer-shell and session-lock surfaces never do. - **`NavStack`** — navigation state for an app made of screens, kept in the app's model: `push` / `pop` / `replace` / `pop_to_root` / `reset` / `current` / `iter`, never empty, serialisable whenever `R` is. `back( msg )` answers `on_back`, and persisting the same value from `save_state` makes back retrace the same screens after a session restore. diff --git a/docs/architecture.md b/docs/architecture.md index d42f3e5..8048a44 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,6 +110,7 @@ In practice, that model is easiest to adopt in three steps: - `is_animating()` — return `true` while a tween is running; the loop redraws on every compositor frame callback (~60 Hz on a typical display, capped at ~30 Hz on the software backend — see *Performance*). - `take_focus_request()` → `Option` — pull-once focus retargeting. - `on_text_input_focused(active)` — surface IME state. +- `external_text_input()` → `Option` with `on_external_text_commit` / `on_external_text_delete` — hold the input method for a text field ltk does not own (an editable in an embedded web view); a new serial renews the request. **Implement for window / toplevel lifecycle** diff --git a/docs/widgets.md b/docs/widgets.md index 7659194..b6e8a00 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -439,6 +439,8 @@ let pw = text_edit( "Password", &self.password ) # }} ``` +`color( c )` sets the colour of the value (the placeholder keeps the theme's), for a field dropped onto a surface the theme does not know about. `on_focus( msg )` emits `msg` when the field takes the keyboard focus — from a press, Tab or `App::take_focus_request` — and not again for a press on the field that already has it; `App::on_text_input_focused` still reports whether *some* text field is focused, `on_focus` says *which*. + `password_toggle` keeps the wipe-on-drop and IME-bypass guarantees of `secure( true )` regardless of the current visibility, so flipping the eye does not weaken the field's threat model at diff --git a/src/app.rs b/src/app.rs index 53a4433..8fb6238 100644 --- a/src/app.rs +++ b/src/app.rs @@ -830,6 +830,17 @@ pub trait App: 'static /// Called when a text-input widget gains or loses focus. fn on_text_input_focused( &mut self, _active: bool ) {} + /// `Some( serial )` holds the input method for a text field ltk does not + /// own (an editable in an embedded web view) while no ltk text widget is + /// focused; a new `serial` renews the request, e.g. on a press on the field. + fn external_text_input( &self ) -> Option { None } + + /// Text committed by the input method while [`Self::external_text_input`] holds it. + fn on_external_text_commit( &mut self, _text: &str ) {} + + /// Delete around the external field's cursor, in UTF-8 bytes as `zwp_text_input_v3` counts. + fn on_external_text_delete( &mut self, _before: u32, _after: u32 ) {} + /// Translate a Wayland `ext-foreign-toplevel-list-v1` event into an /// app message. The runtime binds the protocol globally and forwards /// every open / close it sees here; apps that ignore window state diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index 92bdb71..e8f767e 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -91,6 +91,8 @@ pub struct AppData /// Whether the currently focused text field is `secure` (password). /// Drives the text-input-v3 content type. pub text_input_secure: bool, + /// Serial of the [`App::external_text_input`] request the text-input is held for. + pub external_text_input: Option, /// `xdg-activation-v1`. Present when the compositor advertises the /// global. Used on first configure to honour an incoming /// `XDG_ACTIVATION_TOKEN` (a launcher that spawned us wants the diff --git a/src/event_loop/focus.rs b/src/event_loop/focus.rs index d8f223c..1d1c190 100644 --- a/src/event_loop/focus.rs +++ b/src/event_loop/focus.rs @@ -103,9 +103,19 @@ impl AppData let was_text_input; let is_text_input; let secure; + let focus_msg; { let ss = self.surface_mut( focus ); + focus_msg = idx + .filter( |i| ss.focused_idx != Some( *i ) ) + .and_then( |i| find_handlers( &ss.frame.widget_rects, i ) ) + .and_then( |h| match h + { + WidgetHandlers::TextEdit { on_focus, .. } => on_focus.clone(), + _ => None, + } ); + is_text_input = idx .and_then( |i| find_handlers( &ss.frame.widget_rects, i ) ) .map( |h| h.is_text_input() ) @@ -166,6 +176,8 @@ impl AppData } } + if let Some( msg ) = focus_msg { self.pending_msgs.push( msg ); } + if was_text_input && !is_text_input { self.deactivate_text_input(); diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index f1bc36d..910afba 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -888,22 +888,26 @@ impl Dispatch for AppData _qh: &QueueHandle, ) { - let focus = state.keyboard_focus; + let focus = state.keyboard_focus; + let external = state.external_text_input.is_some() && !state.focused_text_input( focus ); match event { zwp_text_input_v3::Event::CommitString { text } => { - if let Some( text ) = text + if let Some( text ) = text.filter( |t| !t.is_empty() ) { - if !text.is_empty() - { - state.handle_text_insert( focus, &text ); - } + if external { state.app.on_external_text_commit( &text ); } + else { state.handle_text_insert( focus, &text ); } } } zwp_text_input_v3::Event::DeleteSurroundingText { before_length, after_length } => { - state.handle_delete_surrounding( focus, before_length, after_length ); + if external + { + state.app.on_external_text_delete( before_length, after_length ); + } else { + state.handle_delete_surrounding( focus, before_length, after_length ); + } } zwp_text_input_v3::Event::Done { .. } => { diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs index ca309f3..c594a96 100644 --- a/src/event_loop/run.rs +++ b/src/event_loop/run.rs @@ -314,6 +314,7 @@ pub( crate ) fn try_run( mut app: A ) -> Result<(), RunError> text_input_manager, text_input: None, text_input_secure: false, + external_text_input: None, activation_state, activation_token_pending, session, @@ -604,6 +605,7 @@ pub( crate ) fn try_run( mut app: A ) -> Result<(), RunError> } data.sync_fullscreen(); + data.sync_external_text_input(); // Seed `on_drag_move` with the long-press origin for any drag that // just started. Must run *after* `update()` so the app's drag state diff --git a/src/event_loop/text_editing/ime.rs b/src/event_loop/text_editing/ime.rs index aabef54..6e6b570 100644 --- a/src/event_loop/text_editing/ime.rs +++ b/src/event_loop/text_editing/ime.rs @@ -56,6 +56,29 @@ impl AppData } } + /// Follow [`App::external_text_input`] while no ltk text widget holds the focus. + pub( crate ) fn sync_external_text_input( &mut self ) + { + if self.focused_text_input( self.keyboard_focus ) + { + self.external_text_input = None; + return; + } + let wanted = self.app.external_text_input(); + match ( wanted, self.external_text_input ) + { + ( Some( _ ), _ ) if self.text_input.is_none() => + { + let qh = self.qh.clone(); + self.activate_text_input( &qh, false ); + } + ( Some( new ), Some( old ) ) if new != old => self.reenable_text_input(), + ( None, Some( _ ) ) => self.deactivate_text_input(), + _ => {} + } + self.external_text_input = wanted; + } + pub( crate ) fn reenable_text_input( &self ) { if let Some( ti ) = &self.text_input diff --git a/src/widget/element.rs b/src/widget/element.rs index c9ea508..b2186d6 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -314,6 +314,7 @@ impl Element value: t.value.clone(), on_change: t.on_change.clone(), on_submit: t.on_submit.clone(), + on_focus: t.on_focus.clone(), // `secure` here drives memory wipe-on-drop and // the IME bypass — so any password field (with // or without a toggle) opts in, regardless of diff --git a/src/widget/handlers.rs b/src/widget/handlers.rs index ee6cdd6..308912a 100644 --- a/src/widget/handlers.rs +++ b/src/widget/handlers.rs @@ -40,6 +40,7 @@ pub enum WidgetHandlers value: String, on_change: Option Msg>>, on_submit: Option, + on_focus: Option, /// `true` when the source `TextEdit` was built with /// `.secure( true )`. Propagates the wipe-on-drop behaviour to /// every per-frame handler snapshot so credential text does not @@ -140,13 +141,14 @@ impl Clone for WidgetHandlers WidgetHandlers::Radio { on_select, selected } => WidgetHandlers::Radio { on_select: on_select.clone(), selected: *selected }, WidgetHandlers::ListItem { on_press } => WidgetHandlers::ListItem { on_press: on_press.clone() }, WidgetHandlers::WindowButton { on_press } => WidgetHandlers::WindowButton { on_press: on_press.clone() }, - WidgetHandlers::TextEdit { value, on_change, on_submit, secure, multiline, align, font_size, select_on_focus, password_toggle_msg } => + WidgetHandlers::TextEdit { value, on_change, on_submit, on_focus, secure, multiline, align, font_size, select_on_focus, password_toggle_msg } => { WidgetHandlers::TextEdit { value: value.clone(), on_change: on_change.clone(), on_submit: on_submit.clone(), + on_focus: on_focus.clone(), secure: *secure, multiline: *multiline, align: *align, diff --git a/src/widget/text_edit/draw.rs b/src/widget/text_edit/draw.rs index 27e352c..bd5002c 100644 --- a/src/widget/text_edit/draw.rs +++ b/src/widget/text_edit/draw.rs @@ -197,7 +197,7 @@ impl TextEdit rect.x + pad_h + align_x - scroll_x, text_y, font_size, - theme::text(), + self.color.unwrap_or_else( theme::text ), ); } @@ -368,7 +368,7 @@ impl TextEdit rect.x + m.pad_h, y, m.font_size, - theme::text(), + self.color.unwrap_or_else( theme::text ), ); } } diff --git a/src/widget/text_edit/mod.rs b/src/widget/text_edit/mod.rs index 09c6e0a..95e2f14 100644 --- a/src/widget/text_edit/mod.rs +++ b/src/widget/text_edit/mod.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use crate::render::Canvas; use crate::secure_mem::secure_zero; -use crate::types::{ Length, Rect, WidgetId }; +use crate::types::{ Color, Length, Rect, WidgetId }; use super::Element; @@ -105,6 +105,10 @@ pub struct TextEdit pub( crate ) on_change: Option Msg>>, /// Message emitted when the user presses Enter. pub( crate ) on_submit: Option, + /// Message emitted when the field takes the keyboard focus. + pub( crate ) on_focus: Option, + /// Colour of the value. `None` follows the theme. + pub( crate ) color: Option, /// When `true`, the value is rendered as bullet characters (password mode). pub( crate ) secure: bool, /// When `true`, the widget renders as a multi-row text area: the @@ -194,6 +198,8 @@ impl TextEdit value, on_change: None, on_submit: None, + on_focus: None, + color: None, secure: false, multiline: false, rows: theme::ROWS_DEFAULT, @@ -417,6 +423,20 @@ impl TextEdit self } + /// Emit `msg` when the field takes the keyboard focus. + pub fn on_focus( mut self, msg: Msg ) -> Self + { + self.on_focus = Some( msg ); + self + } + + /// Colour of the value; the placeholder keeps the theme's. + pub fn color( mut self, c: Color ) -> Self + { + self.color = Some( c ); + self + } + /// Render the field read-only: shows the value styled as a field but /// takes no focus and accepts no input. pub fn read_only( mut self, on: bool ) -> Self @@ -546,6 +566,8 @@ impl TextEdit value: self.value.clone(), on_change, on_submit: self.on_submit.clone().map( |m| ( *f )( m ) ), + on_focus: self.on_focus.clone().map( |m| ( *f )( m ) ), + color: self.color, secure: self.secure, multiline: self.multiline, rows: self.rows,