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<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:
@@ -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<R>`** — 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.
|
||||
|
||||
@@ -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<WidgetId>` — pull-once focus retargeting.
|
||||
- `on_text_input_focused(active)` — surface IME state.
|
||||
- `external_text_input()` → `Option<u64>` 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**
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
@@ -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<u64> { 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
|
||||
|
||||
@@ -91,6 +91,8 @@ pub struct AppData<A: App>
|
||||
/// 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<u64>,
|
||||
/// `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
|
||||
|
||||
@@ -103,9 +103,19 @@ impl<A: App> AppData<A>
|
||||
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<A: App> AppData<A>
|
||||
}
|
||||
}
|
||||
|
||||
if let Some( msg ) = focus_msg { self.pending_msgs.push( msg ); }
|
||||
|
||||
if was_text_input && !is_text_input
|
||||
{
|
||||
self.deactivate_text_input();
|
||||
|
||||
@@ -888,22 +888,26 @@ impl<A: App> Dispatch<ZwpTextInputV3, ()> for AppData<A>
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
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 { .. } =>
|
||||
{
|
||||
|
||||
@@ -314,6 +314,7 @@ pub( crate ) fn try_run<A: App>( 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<A: App>( 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
|
||||
|
||||
@@ -56,6 +56,29 @@ impl<A: App> AppData<A>
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user