refactor: split every monolithic module into focused submodules

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.
This commit is contained in:
2026-05-15 23:46:56 +02:00
parent 3d237039c6
commit 4aa3480b64
155 changed files with 13832 additions and 13035 deletions

513
src/widget/text_edit/mod.rs Normal file
View File

@@ -0,0 +1,513 @@
// 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 );
}
}
}