Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules. `src/event_loop/mod.rs` (878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves. image bumped from 0.25.2 to 0.25.9.
514 lines
19 KiB
Rust
514 lines
19 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
//! 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<Msg> {
|
|
/// 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<Msg> {
|
|
/// 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<Msg: Clone>
|
|
{
|
|
/// 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<Arc<dyn Fn(String) -> Msg>>,
|
|
/// Message emitted when the user presses Enter.
|
|
pub on_submit: Option<Msg>,
|
|
/// 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<WidgetId>,
|
|
/// Override the pointer cursor shape on hover. `None` falls back
|
|
/// to the I-beam default that matches every desktop convention.
|
|
pub cursor: Option<crate::types::CursorShape>,
|
|
/// 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<f32>,
|
|
/// 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<Msg: Clone> TextEdit<Msg>
|
|
{
|
|
/// 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<Msg>
|
|
{
|
|
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<Msg>
|
|
{
|
|
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<Msg>
|
|
{
|
|
if self.cursor_pos == 0 { return None; }
|
|
let chars: Vec<char> = 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<U>( self, f: &super::MapFn<Msg, U> ) -> TextEdit<U>
|
|
where
|
|
U: Clone + 'static,
|
|
Msg: 'static,
|
|
{
|
|
// Wrap on_change the same way the slider does. on_submit is a
|
|
// plain `Option<Msg>`, so it goes through the user mapper once.
|
|
let on_change = self.on_change.clone().map( |old| -> Arc<dyn Fn( String ) -> 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<Msg: Clone> Drop for TextEdit<Msg>
|
|
{
|
|
/// 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 );
|
|
}
|
|
}
|
|
}
|