// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! Text input field — single-line or multiline. The widget itself //! owns layout / draw; the runtime side of text editing //! (insert, delete, cursor movement, selection, clipboard) //! lives in [`crate::event_loop::text_editing`]. use std::sync::Arc; use crate::render::Canvas; use crate::secure_mem::secure_zero; use crate::types::{ Rect, WidgetId }; use super::Element; pub( crate ) mod theme; pub( crate ) mod wrapping; pub( crate ) mod hit_test; pub( crate ) mod cursor; mod draw; #[ cfg( test ) ] mod tests; pub use draw::password_toggle_hit_zone; pub( crate ) use hit_test::byte_offset_at; pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_home, cursor_visual_up }; /// A text input field. /// /// Single-line by default; switches to a multi-row text-area via /// [`Self::multiline`]. Single-line mode honours the optional inline /// builders for picker-style fields: /// /// * [`Self::align`] — horizontal alignment of the displayed text; /// * [`Self::borderless`] — drop the surrounding pill / border so the /// field can sit inside a parent that already paints its own /// surface; /// * [`Self::fixed_width`] — pin the preferred width to a specific /// number of pixels instead of claiming `max_width`; /// * [`Self::font_size`] — override the text font size; /// * [`Self::select_on_focus`] — auto-select the value on focus so /// the next keystroke replaces it (numeric pickers, short-form /// inputs). /// * [`Self::password_toggle`] — pin a built-in show / hide-password /// eye icon to the right edge of the field; the bullet /// substitution flips with the externally-owned `visible` state /// on each tap. /// /// ```rust,no_run /// # use ltk::{ text_edit, Element }; /// # #[ derive( Clone ) ] enum Msg { UsernameChanged( String ), Submit } /// # struct App { username: String } /// # impl App { fn _ex( &self ) -> Element { /// text_edit( "Username", &self.username ) /// .on_change( |s| Msg::UsernameChanged( s ) ) /// .on_submit( Msg::Submit ) /// .into() /// # }} /// ``` /// /// ## Password field with show / hide toggle /// /// ```rust,no_run /// # use ltk::{ text_edit, Element }; /// # #[ derive( Clone ) ] enum Msg { PasswordChanged( String ), TogglePassword } /// # struct App { password: String, password_visible: bool } /// # impl App { fn _ex( &self ) -> Element { /// text_edit( "Password", &self.password ) /// .on_change( |s| Msg::PasswordChanged( s ) ) /// .password_toggle( self.password_visible, Msg::TogglePassword ) /// .into() /// # }} /// ``` /// /// `password_toggle` overrides [`Self::secure`] when both are set — /// the toggle's `visible` parameter drives the bullet substitution /// from then on. The widget still wipes the buffer on drop and /// skips the IME registration (the same hardening /// [`Self::secure`] gives) regardless of the current visibility, /// so flipping the eye does not weaken the field's threat model /// at runtime. pub struct TextEdit { /// Placeholder text shown when the field is empty. pub placeholder: String, /// Current field value. pub value: String, /// Callback invoked with the new value on every keystroke. /// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf /// handler snapshot for O(1) dispatch on input events. pub on_change: Option Msg>>, /// Message emitted when the user presses Enter. pub on_submit: Option, /// When `true`, the value is rendered as bullet characters (password mode). pub secure: bool, /// When `true`, the widget renders as a multi-row text area: the /// box grows to [`Self::rows`] visible rows, line breaks in the /// value are honoured at draw time, and pressing Enter inserts a /// `\n` rather than firing [`Self::on_submit`]. Ignored when /// [`Self::secure`] is set — passwords are always single-line. pub multiline: bool, /// Visible row count when `multiline` is `true`. Drives /// `preferred_size`'s height calculation so a multiline field /// claims a sensible vertical slot in the parent layout. Ignored /// when `multiline` is `false`. pub rows: u32, /// Byte offset of the text cursor within `value` (used by insert_str/backspace). pub cursor_pos: usize, /// Optional stable identifier for focus management. pub id: Option, /// Override the pointer cursor shape on hover. `None` falls back /// to the I-beam default that matches every desktop convention. pub cursor: Option, /// Horizontal alignment of the displayed text inside the inner /// content rect. Only takes effect on the single-line path when /// the value fits inside the inner width — once the value /// overflows, the internal `single_line_scroll_x` helper takes /// over and the alignment offset collapses to `0` so scrolling /// reads naturally. Default `TextAlign::Left`. pub align: super::text::TextAlign, /// Skip the field's background fill and border stroke. Useful /// when the [`TextEdit`] is dropped inside another container /// that paints its own surface (e.g. the digit cells inside /// [`crate::widget::time_picker::TimePicker`]) and a second pill /// would only add visual noise. pub borderless: bool, /// Override the preferred width reported to the parent layout. /// Without this the single-line `TextEdit` claims `max_width` and /// fills whatever rect the parent allocates — which is the right /// default for forms but wrong when the field needs to be sized /// to fit a fixed number of glyphs (date / time pickers, inline /// numeric inputs). pub fixed_width: Option, /// Font size in pixels for the single-line draw path. Defaults to /// the theme's `FONT_SIZE` constant. Multiline mode ignores this /// for now and always uses the default — multiline soft-wrap /// layout depends on the constant in several places that are not /// yet parameterised. pub font_size: f32, /// When `true`, focusing the field selects the whole value so the /// next keystroke replaces it. Standard behaviour for numeric /// pickers and short-form fields where the user usually wants to /// retype rather than edit. Default `false` — long-form fields /// keep the cursor at the end on focus. pub select_on_focus: bool, /// Self-managed "show / hide password" eye affordance — when /// `Some( ( visible, on_toggle ) )` the field renders an /// `actions/visible` ↔ `actions/invisible` icon at its right /// edge, taps on that icon dispatch `on_toggle` instead of /// placing the cursor, and the bullet substitution flips with /// `visible` (overriding `secure`). Set on a field that already /// has `secure( true )` and the explicit flag becomes redundant /// — the toggle controls the visibility from then on. pub password_toggle: Option<( bool, Msg )>, } impl TextEdit { /// Create a text field with the given placeholder and initial value. /// /// The cursor is placed at the end of the initial value. pub fn new( placeholder: String, value: String ) -> Self { let cursor_pos = value.len(); Self { placeholder, value, on_change: None, on_submit: None, secure: false, multiline: false, rows: theme::ROWS_DEFAULT, cursor_pos, id: None, cursor: None, align: super::text::TextAlign::Left, borderless: false, fixed_width: None, font_size: theme::FONT_SIZE, select_on_focus: false, password_toggle: None, } } /// Add a "show / hide password" eye toggle pinned to the right /// edge of the field. `visible` controls whether the value /// renders as bullets (`false`) or plain text (`true`); a tap on /// the icon emits `on_toggle` so the caller can flip its own /// `bool` state and re-render. Works with or without an explicit /// [`Self::secure`] — when this is set, the toggle's `visible` /// drives the bullet substitution and the `secure` field is /// ignored. pub fn password_toggle( mut self, visible: bool, on_toggle: Msg ) -> Self { self.password_toggle = Some( ( visible, on_toggle ) ); self } /// Effective secure flag honoured by drawing / measurement / /// hit-testing — [`Self::password_toggle`] takes precedence over /// the manual [`Self::secure`] when both are set. pub fn effective_secure( &self ) -> bool { match &self.password_toggle { Some( ( visible, _ ) ) => !visible, None => self.secure, } } /// Override the font size used by the single-line draw path. /// Defaults to the theme's `FONT_SIZE` constant. Ignored in /// multiline mode. pub fn font_size( mut self, px: f32 ) -> Self { self.font_size = px.max( 1.0 ); self } /// Select the whole value when the field receives focus, so the /// next keystroke replaces it. Default `false`. pub fn select_on_focus( mut self, on: bool ) -> Self { self.select_on_focus = on; self } /// Set the horizontal alignment of the displayed text. Default /// [`TextAlign::Left`](super::text::TextAlign::Left). pub fn align( mut self, a: super::text::TextAlign ) -> Self { self.align = a; self } /// Skip the field's background fill and border stroke — useful /// when the field is nested inside a container that already /// paints its own surface. pub fn borderless( mut self, on: bool ) -> Self { self.borderless = on; self } /// Override the preferred width reported to the parent layout. /// Pass `None` (default) to fall back to claiming `max_width`. pub fn fixed_width( mut self, w: f32 ) -> Self { self.fixed_width = Some( w ); self } /// Override the pointer cursor shape shown on hover. Defaults to /// [`CursorShape::Text`](crate::CursorShape::Text) (I-beam). pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self { self.cursor = Some( shape ); self } /// Switch to multiline (text-area) mode. The box is laid out with /// [`Self::rows`] visible rows of height, line breaks in the value /// are rendered as separate rows, and Enter inserts a `\n` instead /// of firing [`Self::on_submit`]. Ignored when [`Self::secure`] is /// `true`. pub fn multiline( mut self, m: bool ) -> Self { self.multiline = m; self } /// Configure the number of visible rows in multiline mode. Defaults /// to 5; ignored when [`Self::multiline`] is `false`. pub fn rows( mut self, n: u32 ) -> Self { self.rows = n.max( 1 ); self } /// Set the callback invoked on every keystroke with the updated value. pub fn on_change( mut self, f: impl Fn(String) -> Msg + 'static ) -> Self { self.on_change = Some( Arc::new( f ) ); self } /// Set the message emitted when Enter is pressed. pub fn on_submit( mut self, msg: Msg ) -> Self { self.on_submit = Some( msg ); self } /// Enable or disable password mode. /// /// When `true`, this widget: /// /// 1. Renders the value as bullet characters (`•`) instead of the /// raw glyphs. /// 2. Forces single-line mode (multiline + secure is mutually /// exclusive — passwords don't have line breaks). /// 3. Wipes the underlying byte buffer with zero before the /// `String` allocation is returned to the allocator. The wipe /// runs in `Drop` for both the `TextEdit` itself and for the /// per-frame [`crate::widget::WidgetHandlers::TextEdit`] snapshot /// the runtime keeps for input dispatch — so the in-tree copies /// that ltk owns never linger as plain text in freed memory. /// /// # Threat model — what `secure` covers /// /// Inside the widget tree the runtime keeps two copies of the /// value for the lifetime of one frame: the `TextEdit` itself and /// the `WidgetHandlers` snapshot. Both run the `secure_zero` wipe /// on `Drop`, so when the next frame replaces them (the typical /// case — `view()` rebuilds every frame) the freed allocations are /// overwritten before being released back to the allocator. The /// wipe uses volatile writes + a `compiler_fence` so the optimiser /// cannot elide it as dead code (the implementation is in the /// crate-private `secure_mem` module). /// /// # What `secure` does **not** cover /// /// * **The application's own state.** The `String` you pass in /// through `text_edit( placeholder, &self.password )` lives on /// **your** struct, not on the widget. Wiping it is /// your job — typically a `Drop` impl on the credential /// container, or an explicit /// `secure_mem::secure_zero( password.as_bytes_mut() )` after /// the auth handshake completes. /// * **Callback-allocated copies.** Every keystroke passes through /// `on_change( |s: String| ... )`, which receives a fresh /// `String` clone. If your closure stores or forwards that /// `String` (e.g. clone it into a worker thread for PAM), each /// stored copy is the consumer's responsibility to wipe. ltk /// only owns the buffers it allocated itself. /// * **OS-level disclosure surfaces.** Swap-out, hibernation /// images, and core dumps are outside any user-space wipe's /// reach. For threat models that require resistance to these, /// compile against an `mlock`-aware allocator, disable swap on /// the credential mount, and restrict core-dump capability with /// `prctl( PR_SET_DUMPABLE, 0 )` on the process. /// * **Compositor-side records.** Wayland text-input protocols can /// surface preedit / commit strings to the compositor's IME /// stack; `secure` skips text-input-v3 registration so this path /// stays closed. Verify your compositor honours that — most do, /// but the protocol does not strictly require it. /// /// See the in-repo `SECURITY.md` for the full threat-model write-up /// (the *Hardening features* section enumerates each guarantee and /// its boundary). pub fn secure( mut self, s: bool ) -> Self { self.secure = s; self } /// Assign a stable identifier for focus management. pub fn id( mut self, id: WidgetId ) -> Self { self.id = Some( id ); self } /// Return the preferred `(width, height)` given available `max_width`. /// /// Single-line: theme-defined `HEIGHT`. /// Multiline: enough room for [`Self::rows`] lines plus padding. pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32) { if self.multiline && !self.effective_secure() { let line_h = theme::FONT_SIZE * theme::LINE_H_MULT; let h = self.rows as f32 * line_h + theme::PAD_V_MULTI * 2.0; ( max_width, h ) } else { let w = self.fixed_width .map( |fw| fw.min( max_width ) ) .unwrap_or( max_width ); ( w, theme::HEIGHT ) } } /// `true` when the widget is laid out as a multi-row text area — /// i.e. [`Self::multiline`] was set and [`Self::effective_secure`] /// is `false`. A `password_toggle` field collapses to single-line /// like an explicit `secure( true )` does. pub fn is_multiline( &self ) -> bool { self.multiline && !self.effective_secure() } /// Translate a pointer position inside `rect` to the byte offset /// in [`Self::value`] that the cursor should land on. Thin /// wrapper around `byte_offset_at` using this widget's value / /// flags. pub fn byte_offset_at_self( &self, canvas: &Canvas, rect: Rect, pos: crate::types::Point, cursor_pos: usize, ) -> usize { byte_offset_at( canvas, rect, pos, &self.value, self.is_multiline(), self.effective_secure(), cursor_pos, self.align, self.font_size, ) } /// Border stroke is centered on `rect`, so half the stroke width plus ~1 px /// of antialiasing bleed sits outside. The widest stroke is the focused /// border, so use that as the envelope. pub fn paint_bounds( &self, rect: Rect ) -> Rect { rect.expand( theme::FOCUS_BORDER_W * 0.5 + 1.0 ) } /// Return the display string — bullet characters in secure mode, plain value otherwise. pub fn display_text( &self ) -> String { if self.effective_secure() { "\u{2022}".repeat( self.value.chars().count() ) } else { self.value.clone() } } /// Wrap this widget in an [`Element`]. pub fn into_element( self ) -> Element { Element::TextEdit( self ) } /// Insert a string at the current cursor position, advance the cursor, and /// return the `on_change` message if one is set. pub fn insert_str( &mut self, s: &str ) -> Option { self.value.insert_str( self.cursor_pos.min( self.value.len() ), s ); self.cursor_pos = (self.cursor_pos + s.len()).min( self.value.len() ); self.on_change.as_ref().map( |f| f( self.value.clone() ) ) } /// Delete the character before the cursor and return the `on_change` message /// if one is set. Does nothing if the cursor is already at position 0. pub fn backspace( &mut self ) -> Option { if self.cursor_pos == 0 { return None; } let chars: Vec = self.value.chars().collect(); let char_pos = self.value[..self.cursor_pos].chars().count(); if char_pos == 0 { return None; } let mut chars = chars; let removed = chars.remove( char_pos - 1 ); self.cursor_pos -= removed.len_utf8(); self.value = chars.iter().collect(); self.on_change.as_ref().map( |f| f( self.value.clone() ) ) } pub( crate ) fn map_msg( self, f: &super::MapFn ) -> TextEdit where U: Clone + 'static, Msg: 'static, { // Wrap on_change the same way the slider does. on_submit is a // plain `Option`, so it goes through the user mapper once. let on_change = self.on_change.clone().map( |old| -> Arc U> { let mapper = Arc::clone( f ); Arc::new( move |s| ( *mapper )( ( *old )( s ) ) ) } ); TextEdit { placeholder: self.placeholder.clone(), value: self.value.clone(), on_change, on_submit: self.on_submit.clone().map( |m| ( *f )( m ) ), secure: self.secure, multiline: self.multiline, rows: self.rows, cursor_pos: self.cursor_pos, id: self.id, cursor: self.cursor, align: self.align, borderless: self.borderless, fixed_width: self.fixed_width, font_size: self.font_size, select_on_focus: self.select_on_focus, password_toggle: self.password_toggle.clone().map( |( v, m )| ( v, ( *f )( m ) ) ), } } } impl Drop for TextEdit { /// When `secure( true )` is set, scrub the value bytes before the /// underlying `String` allocation is returned to the allocator. The /// non-secure path is a no-op so the cost is paid only by widgets /// that opted into credential handling. fn drop( &mut self ) { if self.secure || self.password_toggle.is_some() { // SAFETY: as_mut_vec exposes the underlying byte buffer of the // String. We only overwrite each byte with zero, which is valid // UTF-8 (a sequence of NUL codepoints), so the String invariant // is preserved through to the Vec drop that runs immediately // after this fn returns. let bytes = unsafe { self.value.as_mut_vec() }; secure_zero( bytes ); } } }