// 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 the `event_loop::text_editing` private module. use std::sync::Arc; use crate::render::Canvas; use crate::secure_mem::secure_zero; use crate::types::{ Length, Rect, WidgetId }; use super::Element; pub( crate ) mod theme; pub( crate ) mod wrapping; pub( crate ) mod hit_test; pub( crate ) mod cursor; pub( crate ) mod metrics; 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 }; /// Resolve the font size into a concrete logical px. `Some( len )` resolves /// against [`Canvas::viewport_logical`] — the font space, so `× dpi_scale` at /// raster yields the right physical size — exactly like the button label; /// `None` follows the widget-scaling mode at the theme default. Shared by the /// draw and hit-test paths, which all carry a canvas. pub( crate ) fn resolve_font_size( canvas: &Canvas, fs: Option ) -> f32 { fs .map( |l| canvas.resolve_font( l ) ) .unwrap_or_else( || canvas.font_px( theme::FONT_SIZE ) ) } /// 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( crate ) placeholder: String, /// Current field value. pub( crate ) 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( crate ) on_change: Option Msg>>, /// Message emitted when the user presses Enter. pub( crate ) on_submit: 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 /// 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( crate ) 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( crate ) rows: u32, /// Byte offset of the text cursor within `value` (used by insert_str/backspace). pub( crate ) cursor_pos: usize, /// Optional stable identifier for focus management. pub( crate ) id: Option, /// Override the pointer cursor shape on hover. `None` falls back /// to the I-beam default that matches every desktop convention. pub( crate ) 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( crate ) 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( crate ) 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( crate ) fixed_width: Option, /// Font size in pixels for the single-line draw path. `0.0` (the /// Label font size. `None` follows the process [`crate::WidgetScaling`] /// mode at the theme default (via [`crate::Canvas::font_px`]); a /// [`Length`] pins it and is resolved in font space /// ([`crate::Canvas::viewport_logical`]), exactly like the button label. /// Resolved via [`Self::effective_font_size`] by every consumer, all of /// which carry a canvas. Multiline mode ignores this and always uses the /// theme constant. pub( crate ) font_size: Option, /// Optional single-line field height. `None` uses the theme default /// (`theme::HEIGHT`); a [`Length`] scales the box with the surface, so /// a field can match a fluid button or grow with a fluid form. Resolved /// in physical layout space, like all geometry. Ignored in multiline /// mode, where the height follows [`Self::rows`]. pub( crate ) height: Option, /// 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( crate ) 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( crate ) password_toggle: Option<( bool, Msg )>, /// When `true`, the field renders its box and value but takes no keyboard /// focus and accepts no input — a read-only display styled as a text field. pub( crate ) read_only: bool, } 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: None, height: None, select_on_focus: false, password_toggle: None, read_only: false, } } /// 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, } } /// Set the label font size. Accepts logical `f32` pixels or any /// [`Length`] (e.g. `Length::vmin( 4.5 ).clamp( 16.0, 32.0 )` to scale /// with the surface), resolved in font space exactly like the button /// label. Without any font-size call the size follows the process /// [`crate::WidgetScaling`] mode at the theme default. Ignored in /// multiline mode. pub fn font_size( mut self, size: impl Into ) -> Self { self.font_size = Some( size.into() ); self } /// Set a font size that follows the process [`crate::WidgetScaling`] /// mode at the given design px — the fluid counterpart of /// [`Self::font_size`]. Shorthand for `.font_size( Length::fluid( n ) )`. pub fn font_size_fluid( mut self, design_px: f32 ) -> Self { self.font_size = Some( Length::fluid( design_px.max( 1.0 ) ) ); self } /// Resolve the single-line font size against the widget-scaling mode. pub( crate ) fn effective_font_size( &self, canvas: &Canvas ) -> f32 { resolve_font_size( canvas, self.font_size ) } /// Set the single-line field height. Accepts logical `f32` pixels or any /// [`Length`] (e.g. `Length::vmin( 9.0 ).clamp( 44.0, 72.0 )` to scale /// the box with the surface, or to match a fluid button). Ignored in /// multiline mode, where the height follows [`Self::rows`]. pub fn height( mut self, h: impl Into ) -> Self { self.height = Some( h.into() ); 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. /// Accepts logical `f32` pixels or any [`Length`] — pair a /// [`Length::fluid`] width with a fluid font size so box and glyphs /// scale together. Without this the field claims `max_width`. pub fn fixed_width( mut self, w: impl Into ) -> Self { self.fixed_width = Some( w.into() ); 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` does *not* suppress text-input-v3 — the field still /// registers so the on-screen keyboard activates on it — but it is /// flagged `Password` with `SensitiveData | HiddenText`, asking the /// IME / OSK to skip prediction, autocorrect and storing the value. /// The value still reaches the (trusted) compositor/IME; for a /// stricter threat model, suppress text-input on secure fields /// instead (at the cost of losing the OSK there). /// /// 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 } /// 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 { self.read_only = on; 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 m = metrics::MultilineMetrics::resolve( canvas, self.font_size ); ( max_width, m.height_for_rows( self.rows ) ) } else { let w = self.fixed_width .map( |l| canvas.resolve_geom( l ).min( max_width ) ) .unwrap_or( max_width ); let h = self.height .map( |l| canvas.resolve_geom( l ) ) .unwrap_or_else( || canvas.geom_px( theme::HEIGHT ) ); ( w, h ) } } /// `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 the field's `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, height: self.height, select_on_focus: self.select_on_focus, password_toggle: self.password_toggle.clone().map( |( v, m )| ( v, ( *f )( m ) ) ), read_only: self.read_only, } } } 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 ); } } }