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,