External text input for fields ltk does not own; TextEdit::color and TextEdit::on_focus
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

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<u64>` 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.
This commit is contained in:
2026-09-21 12:45:09 +02:00
parent 96a9e88051
commit 2af281520b
13 changed files with 95 additions and 11 deletions
+1
View File
@@ -314,6 +314,7 @@ impl<Msg: Clone> Element<Msg>
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
+3 -1
View File
@@ -40,6 +40,7 @@ pub enum WidgetHandlers<Msg: Clone>
value: String,
on_change: Option<Arc<dyn Fn( String ) -> Msg>>,
on_submit: Option<Msg>,
on_focus: Option<Msg>,
/// `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<Msg: Clone> Clone for WidgetHandlers<Msg>
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,
+2 -2
View File
@@ -197,7 +197,7 @@ impl<Msg: Clone> TextEdit<Msg>
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<Msg: Clone> TextEdit<Msg>
rect.x + m.pad_h,
y,
m.font_size,
theme::text(),
self.color.unwrap_or_else( theme::text ),
);
}
}
+23 -1
View File
@@ -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<Msg: Clone>
pub( crate ) on_change: Option<Arc<dyn Fn(String) -> Msg>>,
/// Message emitted when the user presses Enter.
pub( crate ) on_submit: Option<Msg>,
/// Message emitted when the field takes the keyboard focus.
pub( crate ) on_focus: Option<Msg>,
/// Colour of the value. `None` follows the theme.
pub( crate ) color: Option<Color>,
/// 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<Msg: Clone> TextEdit<Msg>
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<Msg: Clone> TextEdit<Msg>
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<Msg: Clone> TextEdit<Msg>
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,