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

View File

@@ -0,0 +1,108 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::Rect;
use super::hit_test::byte_offset_in_line;
use super::theme;
use super::wrapping::{ compute_visual_lines, VisualLine };
/// Locate the visual line containing `cursor` in the wrap layout for
/// `value` inside `rect`. When the cursor sits exactly on a soft-wrap
/// boundary (so it satisfies both the previous line's `end` and the
/// next line's `start`), prefer the *previous* line — that matches the
/// rendering convention in `draw_multiline`, which paints the caret at
/// the trailing edge of the prior visual row rather than the leading
/// edge of the new one.
fn current_visual_line(
canvas: &Canvas,
rect: Rect,
value: &str,
cursor: usize,
) -> Option<( Vec<VisualLine>, usize )>
{
let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE );
let visual_lines = compute_visual_lines( canvas, value, inner_width, theme::FONT_SIZE );
if visual_lines.is_empty() { return None; }
let safe_cursor = cursor.min( value.len() );
let idx = visual_lines.iter()
.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
.unwrap_or( visual_lines.len() - 1 );
Some( ( visual_lines, idx ) )
}
/// Move the text cursor one visual row up. Returns the new byte offset
/// or `None` when the cursor is already on the first visual row, so
/// the caller can fall through to sibling-widget keyboard navigation.
///
/// Visual X is preserved across the move: the new cursor lands as close
/// as possible (with snap-to-nearest-glyph) to the same horizontal
/// position the cursor had on its origin line. No "preferred column"
/// state is kept across multiple consecutive Up presses, so a long
/// short → long sequence will drift toward the end of every short
/// line — same simplification GTK / Cocoa make for plain controls.
pub( crate ) fn cursor_visual_up(
canvas: &Canvas,
rect: Rect,
value: &str,
cursor: usize,
) -> Option<usize>
{
let ( lines, idx ) = current_visual_line( canvas, rect, value, cursor )?;
if idx == 0 { return None; }
let cur = lines[ idx ];
let safe = cursor.min( value.len() );
let target_x = canvas.measure_text( &value[cur.start..safe], theme::FONT_SIZE );
let prev = lines[ idx - 1 ];
Some( byte_offset_in_line( canvas, value, prev.start, prev.end, target_x, false, theme::FONT_SIZE ) )
}
/// Mirror of [`cursor_visual_up`] for the Down arrow.
pub( crate ) fn cursor_visual_down(
canvas: &Canvas,
rect: Rect,
value: &str,
cursor: usize,
) -> Option<usize>
{
let ( lines, idx ) = current_visual_line( canvas, rect, value, cursor )?;
if idx + 1 >= lines.len() { return None; }
let cur = lines[ idx ];
let safe = cursor.min( value.len() );
let target_x = canvas.measure_text( &value[cur.start..safe], theme::FONT_SIZE );
let next = lines[ idx + 1 ];
Some( byte_offset_in_line( canvas, value, next.start, next.end, target_x, false, theme::FONT_SIZE ) )
}
/// Byte offset of the start of the cursor's current visual line.
/// Falls back to `0` when the wrap layout cannot be computed (e.g.
/// degenerate rect), so the Home key always has a sensible target.
pub( crate ) fn cursor_visual_home(
canvas: &Canvas,
rect: Rect,
value: &str,
cursor: usize,
) -> usize
{
current_visual_line( canvas, rect, value, cursor )
.map( |( lines, idx )| lines[ idx ].start )
.unwrap_or( 0 )
}
/// Byte offset of the end of the cursor's current visual line. For a
/// hard-`\n`-terminated row this is the byte just before the newline;
/// for a soft-wrapped row it is the wrap point (which is also the
/// start of the next visual row, but the caret renders at the trailing
/// edge of *this* row by convention).
pub( crate ) fn cursor_visual_end(
canvas: &Canvas,
rect: Rect,
value: &str,
cursor: usize,
) -> usize
{
current_visual_line( canvas, rect, value, cursor )
.map( |( lines, idx )| lines[ idx ].end )
.unwrap_or( value.len() )
}

View File

@@ -0,0 +1,397 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::Rect;
use super::TextEdit;
use super::hit_test::{ single_line_align_offset, single_line_scroll_x };
use super::theme;
use super::wrapping::compute_visual_lines;
/// Axis-aligned intersection of two rects. Returns `None` when the rects
/// do not overlap (zero-area / negative-extent results count as
/// "no overlap" so the caller can `filter_map` straight into a clip
/// list).
fn rect_intersect( a: Rect, b: Rect ) -> Option<Rect>
{
let x0 = a.x.max( b.x );
let y0 = a.y.max( b.y );
let x1 = ( a.x + a.width ).min( b.x + b.width );
let y1 = ( a.y + a.height ).min( b.y + b.height );
if x1 > x0 && y1 > y0
{
Some( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } )
} else {
None
}
}
/// Hit rect for the eye icon at the right edge of a `TextEdit`
/// configured with [`TextEdit::password_toggle`]. Returned in the
/// same coordinate space as the field's `rect`. Pointer / touch
/// dispatch consult this before falling through to cursor placement
/// — a tap inside the zone fires the toggle message instead of
/// moving the caret.
pub fn password_toggle_hit_zone( rect: Rect ) -> Rect
{
let zone_w = ( theme::PASSWORD_TOGGLE_SIZE
+ theme::PASSWORD_TOGGLE_SLOP * 2.0
+ theme::PAD_H * 0.5 ).min( rect.width );
Rect
{
x: rect.x + rect.width - zone_w,
y: rect.y,
width: zone_w,
height: rect.height,
}
}
impl<Msg: Clone> TextEdit<Msg>
{
/// Draw the field into `canvas` at `rect`.
///
/// `cursor_pos` is the byte offset of the text cursor — supplied by the runtime
/// from its persistent cursor state rather than from the widget itself.
/// `selection_anchor` is the other end of the selection range; when
/// `selection_anchor == cursor_pos` no highlight is painted.
pub fn draw(
&self,
canvas: &mut Canvas,
rect: Rect,
focused: bool,
cursor_pos: usize,
selection_anchor: usize,
)
{
if self.is_multiline()
{
self.draw_multiline( canvas, rect, focused, cursor_pos, selection_anchor );
return;
}
// Borderless fields skip the surface fill + border stroke
// entirely. They still get the inner clip + scroll + alignment
// treatment below, so the field behaves like a regular
// text input — only the chrome is suppressed.
if !self.borderless
{
let border_c = if focused { theme::focus_border() } else { theme::border() };
let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W };
canvas.fill_rect( rect, theme::bg(), theme::RADIUS );
canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS );
}
let font_size = self.font_size;
let text_y = rect.y + (rect.height + font_size) / 2.0 - 2.0;
let text = self.display_text();
let secure = self.effective_secure();
// When `password_toggle` is set we reserve a column on the
// right edge for the eye icon: scroll / align / clip all
// behave as if the field were narrower so cursor + text
// never slide under the icon.
let toggle_reserve = if self.password_toggle.is_some()
{
theme::PASSWORD_TOGGLE_SIZE + theme::PASSWORD_TOGGLE_SLOP * 2.0
}
else
{
0.0
};
let text_rect = Rect
{
width: ( rect.width - toggle_reserve ).max( theme::PAD_H * 2.0 ),
..rect
};
let scroll_x = single_line_scroll_x( canvas, text_rect, &self.value, cursor_pos, secure, font_size );
let align_x = single_line_align_offset( canvas, text_rect, &self.value, secure, self.align, font_size );
// Clip text / cursor / selection to the inner band so the
// scrolled-off portion does not bleed past the pill stroke.
// Save the parent clip first, intersect with our inner rect,
// and restore on exit so a `text_edit` wrapped in scroll(...)
// keeps the parent's clip honoured.
let outer_clip = canvas.clip_bounds();
let inner_rect = Rect
{
x: rect.x + theme::PAD_H * 0.5,
y: rect.y,
width: ( rect.width - theme::PAD_H - toggle_reserve ).max( 0.0 ),
height: rect.height,
};
let composed_clip: Vec<Rect> = if outer_clip.is_empty()
{
vec![ inner_rect ]
} else {
outer_clip.iter()
.filter_map( |o| rect_intersect( *o, inner_rect ) )
.collect()
};
canvas.set_clip_rects( &composed_clip );
// Selection highlight (single-line). Painted before the text
// so the glyphs sit on top of the tinted band.
if focused && selection_anchor != cursor_pos
{
let ( s, e ) = (
cursor_pos.min( selection_anchor ).min( self.value.len() ),
cursor_pos.max( selection_anchor ).min( self.value.len() ),
);
let prefix_text = if secure
{
"\u{2022}".repeat( self.value[..s].chars().count() )
} else {
self.value[..s].to_string()
};
let span_text = if secure
{
"\u{2022}".repeat( self.value[s..e].chars().count() )
} else {
self.value[s..e].to_string()
};
let x0 = rect.x + theme::PAD_H + align_x - scroll_x
+ canvas.measure_text( &prefix_text, font_size );
let w = canvas.measure_text( &span_text, font_size );
let sel_rect = Rect
{
x: x0, y: rect.y + 6.0,
width: w, height: rect.height - 12.0,
};
canvas.fill_rect( sel_rect, theme::selection(), 2.0 );
}
if text.is_empty()
{
// Placeholder follows the same alignment as the value
// would, so an empty centred / right-aligned field still
// reads with the placeholder where the digits will land.
let placeholder_align_x = single_line_align_offset(
canvas, rect, &self.placeholder, false, self.align, font_size,
);
canvas.draw_text(
&self.placeholder,
rect.x + theme::PAD_H + placeholder_align_x,
text_y,
font_size,
theme::placeholder(),
);
} else {
canvas.draw_text(
&text,
rect.x + theme::PAD_H + align_x - scroll_x,
text_y,
font_size,
theme::text(),
);
}
if focused
{
let safe_cursor = cursor_pos.min( self.value.len() );
let cursor_text = if secure
{
"\u{2022}".repeat( self.value[..safe_cursor].chars().count() )
} else {
self.value[..safe_cursor].to_string()
};
let cursor_x = rect.x + theme::PAD_H + align_x - scroll_x
+ canvas.measure_text( &cursor_text, font_size );
let cursor_rect = Rect
{
x: cursor_x,
y: rect.y + 8.0,
width: 2.0,
height: rect.height - 16.0,
};
canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 );
}
// Restore whatever clip was active on entry.
if outer_clip.is_empty()
{
canvas.clear_clip();
} else {
canvas.set_clip_rects( &outer_clip );
}
// Eye icon for `password_toggle`. Drawn outside the inner
// clip so it never gets occluded by overflowing text — the
// preceding clip-restore is the reason this lives here and
// not earlier in the function. Tinted with the placeholder
// colour so the icon reads as ambient affordance rather
// than a primary control.
if let Some( ( visible, _ ) ) = self.password_toggle.as_ref()
{
let icon_name = if *visible { "actions/invisible" } else { "actions/visible" };
let icon_px = theme::PASSWORD_TOGGLE_SIZE.round() as u32;
if let Some( ( rgba, iw, ih ) ) = crate::theme::icon_rgba( icon_name, icon_px )
{
let tinted = crate::theme::tint_symbolic( &rgba, theme::placeholder() );
let zone = password_toggle_hit_zone( rect );
let dest = Rect
{
x: ( zone.x + ( zone.width - iw as f32 ) / 2.0 ).round(),
y: ( rect.y + ( rect.height - ih as f32 ) / 2.0 ).round(),
width: iw as f32,
height: ih as f32,
};
canvas.draw_image_data( &tinted, iw, ih, dest, 1.0 );
}
}
}
fn draw_multiline(
&self,
canvas: &mut Canvas,
rect: Rect,
focused: bool,
cursor_pos: usize,
selection_anchor: usize,
)
{
let border_c = if focused { theme::focus_border() } else { theme::border() };
let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W };
canvas.fill_rect( rect, theme::bg(), theme::RADIUS_MULTI );
canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS_MULTI );
let line_h = theme::FONT_SIZE * theme::LINE_H_MULT;
let inner_h = ( rect.height - theme::PAD_V_MULTI * 2.0 ).max( line_h );
let visible_lines = ( inner_h / line_h ).floor().max( 1.0 ) as usize;
let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE );
// Compute the visual-line layout once per draw. Iterators
// later go through this list; long logical lines are
// soft-wrapped at the last whitespace before the row would
// overflow without mutating the buffer.
let visual_lines = compute_visual_lines( canvas, &self.value, inner_width, theme::FONT_SIZE );
// Vertical auto-scroll: keep the cursor's visual line on
// screen. The cursor sits in the *first* visual line whose
// `end >= cursor` — at a wrap boundary that places it at the
// trailing edge of the previous line rather than the start of
// the next, matching the convention of every standard text
// editor.
let safe_cursor = cursor_pos.min( self.value.len() );
let cursor_visual_idx = visual_lines.iter()
.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
.unwrap_or( visual_lines.len().saturating_sub( 1 ) );
let total_lines = visual_lines.len();
let max_first = total_lines.saturating_sub( visible_lines );
let first_line = if cursor_visual_idx + 1 > visible_lines
{
( cursor_visual_idx + 1 - visible_lines ).min( max_first )
} else { 0 };
let baseline_0 = rect.y + theme::PAD_V_MULTI + theme::FONT_SIZE;
// Clip text to the inner rect so partial lines at the bottom
// stay inside the box and do not bleed onto sibling widgets
// or the border stroke. Save the parent clip first, then
// intersect it with our inner rect, so a `scroll(...)`-wrapped
// multiline edit keeps the parent's clip honoured.
let outer_clip = canvas.clip_bounds();
let inner_rect = Rect
{
x: rect.x + theme::PAD_H * 0.5,
y: rect.y + theme::PAD_V_MULTI * 0.5,
width: ( rect.width - theme::PAD_H ).max( 0.0 ),
height: ( rect.height - theme::PAD_V_MULTI ).max( 0.0 ),
};
let composed_clip: Vec<Rect> = if outer_clip.is_empty()
{
vec![ inner_rect ]
} else {
outer_clip.iter()
.filter_map( |o| rect_intersect( *o, inner_rect ) )
.collect()
};
canvas.set_clip_rects( &composed_clip );
// Selection highlight (multiline). Painted before the text so
// the glyphs sit on top of the tinted band(s). Iterates
// visual lines, so a soft-wrapped logical line gets one
// highlight rect per visual row automatically.
if focused && selection_anchor != cursor_pos
{
let s = cursor_pos.min( selection_anchor ).min( self.value.len() );
let e = cursor_pos.max( selection_anchor ).min( self.value.len() );
for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line )
{
let visual_idx = i - first_line;
let y = rect.y + theme::PAD_V_MULTI + visual_idx as f32 * line_h;
if y > rect.y + rect.height - theme::PAD_V_MULTI { break; }
// Intersection of [s, e] with this visual line's
// byte range. Empty / non-overlapping → skip.
let span_start = s.max( vl.start );
let span_end = e.min( vl.end );
if span_start >= span_end { continue; }
let prefix_text = &self.value[ vl.start..span_start ];
let span_text = &self.value[ span_start..span_end ];
let x0 = rect.x + theme::PAD_H + canvas.measure_text( prefix_text, theme::FONT_SIZE );
let w = canvas.measure_text( span_text, theme::FONT_SIZE ).max( 4.0 );
let sel_rect = Rect
{
x: x0, y,
width: w, height: line_h,
};
canvas.fill_rect( sel_rect, theme::selection(), 2.0 );
}
}
if self.value.is_empty()
{
canvas.draw_text(
&self.placeholder,
rect.x + theme::PAD_H,
baseline_0,
theme::FONT_SIZE,
theme::placeholder(),
);
} else {
for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line )
{
let visual_idx = i - first_line;
let y = baseline_0 + visual_idx as f32 * line_h;
if y > rect.y + rect.height - theme::PAD_V_MULTI + line_h
{
break;
}
let line = &self.value[ vl.start..vl.end ];
canvas.draw_text(
line,
rect.x + theme::PAD_H,
y,
theme::FONT_SIZE,
theme::text(),
);
}
}
if focused
{
let vl = visual_lines[ cursor_visual_idx ];
let line_prefix = &self.value[ vl.start..safe_cursor ];
let cursor_x = rect.x + theme::PAD_H
+ canvas.measure_text( line_prefix, theme::FONT_SIZE );
let visual_y_idx = cursor_visual_idx.saturating_sub( first_line );
let cursor_y = rect.y + theme::PAD_V_MULTI + visual_y_idx as f32 * line_h;
let cursor_rect = Rect
{
x: cursor_x,
y: cursor_y + 2.0,
width: 2.0,
height: theme::FONT_SIZE + 4.0,
};
canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 );
}
// Restore whatever clip was active on entry — a single empty
// `Vec` from `clip_bounds` means "no clip", which we model by
// calling `clear_clip` instead of pushing an empty slice.
if outer_clip.is_empty()
{
canvas.clear_clip();
} else {
canvas.set_clip_rects( &outer_clip );
}
}
}

View File

@@ -0,0 +1,203 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::{ Point, Rect };
use super::theme;
use super::wrapping::compute_visual_lines;
/// Convert a pointer position inside the widget rect to the byte offset
/// in `value` the cursor should land on. Free function so the runtime
/// can call it with the value snapshot it already holds in
/// [`crate::widget::WidgetHandlers::TextEdit`] without re-walking the
/// element tree to find the original [`super::TextEdit`] struct.
///
/// `multiline` and `secure` mirror the matching fields on the source
/// widget; `cursor_pos` is the current cursor — needed by the
/// multiline branch to reproduce the same vertical scroll the most
/// recent [`super::TextEdit::draw`] call computed.
pub( crate ) fn byte_offset_at(
canvas: &Canvas,
rect: Rect,
pos: Point,
value: &str,
multiline: bool,
secure: bool,
cursor_pos: usize,
align: crate::widget::text::TextAlign,
font_size: f32,
) -> usize
{
if value.is_empty() { return 0; }
if multiline && !secure
{
let line_h = theme::FONT_SIZE * theme::LINE_H_MULT;
let inner_h = ( rect.height - theme::PAD_V_MULTI * 2.0 ).max( line_h );
let visible_lines = ( inner_h / line_h ).floor().max( 1.0 ) as usize;
let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE );
// Recompute the visual layout from current state. Same call
// as `draw_multiline` makes, so the click maps to the line /
// column the user actually sees.
let visual_lines = compute_visual_lines( canvas, value, inner_width, theme::FONT_SIZE );
let safe_cursor = cursor_pos.min( value.len() );
let cursor_visual_idx = visual_lines.iter()
.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
.unwrap_or( visual_lines.len().saturating_sub( 1 ) );
let total_lines = visual_lines.len();
let max_first = total_lines.saturating_sub( visible_lines );
let first_line = if cursor_visual_idx + 1 > visible_lines
{
( cursor_visual_idx + 1 - visible_lines ).min( max_first )
} else { 0 };
let visual_idx = ( ( pos.y - rect.y - theme::PAD_V_MULTI ) / line_h )
.max( 0.0 ) as usize;
let target_idx = ( first_line + visual_idx ).min( total_lines.saturating_sub( 1 ) );
let vl = visual_lines[ target_idx ];
byte_offset_in_line( canvas, value, vl.start, vl.end, pos.x - rect.x - theme::PAD_H, false, theme::FONT_SIZE )
} else {
// Mirror the horizontal scroll *and* alignment the renderer
// applies so a click lands on the glyph the user actually
// sees, not on the byte offset that would correspond to an
// unscrolled, left-aligned layout.
let scroll_x = single_line_scroll_x( canvas, rect, value, cursor_pos, secure, font_size );
let align_x = single_line_align_offset( canvas, rect, value, secure, align, font_size );
byte_offset_in_line(
canvas, value, 0, value.len(),
pos.x - rect.x - theme::PAD_H - align_x + scroll_x,
secure,
font_size,
)
}
}
/// Horizontal scroll offset, in pixels, applied to a single-line
/// `text_edit` so the cursor stays visible inside the inner rect when
/// the value is wider than the box. Stateless — derived purely from
/// the current cursor position. Three regimes:
///
/// * cursor in the **left half** of the visible band → no scroll, the
/// start of the text reads naturally;
/// * cursor in the **right half from the end** → anchor to end so the
/// tail of the value is in view (this is the "typing past the right
/// edge" case the user sees the most);
/// * cursor anywhere else → centre the cursor in the visible band so
/// navigation with the arrow keys keeps trailing context on both
/// sides instead of jumping the text on every keystroke.
///
/// `secure` toggles bullet substitution before measuring so a
/// password field scrolls based on the displayed bullets, not the
/// underlying value's glyph widths.
pub( crate ) fn single_line_scroll_x(
canvas: &Canvas,
rect: Rect,
value: &str,
cursor: usize,
secure: bool,
font_size: f32,
) -> f32
{
let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( font_size );
let safe_cursor = cursor.min( value.len() );
let display_value: String = if secure
{
"\u{2022}".repeat( value.chars().count() )
} else {
value.to_string()
};
let prefix_text: String = if secure
{
"\u{2022}".repeat( value[..safe_cursor].chars().count() )
} else {
value[..safe_cursor].to_string()
};
let total_w = canvas.measure_text( &display_value, font_size );
if total_w <= inner_width { return 0.0; }
let prefix_w = canvas.measure_text( &prefix_text, font_size );
let suffix_w = ( total_w - prefix_w ).max( 0.0 );
const MARGIN: f32 = 4.0;
if prefix_w <= inner_width / 2.0
{
0.0
} else if suffix_w <= inner_width / 2.0 {
( total_w - inner_width + MARGIN ).max( 0.0 )
} else {
prefix_w - inner_width / 2.0
}
}
/// Horizontal offset, in pixels, applied on top of `single_line_scroll_x`
/// to honour the field's [`crate::widget::text::TextAlign`]. Only meaningful
/// while the value still fits inside `inner_width`; once the text
/// overflows, scrolling owns the layout and the alignment offset
/// collapses to `0` so anchoring the cursor / end of text reads
/// naturally without fighting the alignment.
pub( crate ) fn single_line_align_offset(
canvas: &Canvas,
rect: Rect,
value: &str,
secure: bool,
align: crate::widget::text::TextAlign,
font_size: f32,
) -> f32
{
use crate::widget::text::TextAlign;
if matches!( align, TextAlign::Left ) { return 0.0; }
let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( font_size );
let display_value: String = if secure
{
"\u{2022}".repeat( value.chars().count() )
} else {
value.to_string()
};
let total_w = canvas.measure_text( &display_value, font_size );
if total_w >= inner_width { return 0.0; }
match align
{
TextAlign::Left => 0.0,
TextAlign::Center => ( inner_width - total_w ) * 0.5,
TextAlign::Right => inner_width - total_w,
}
}
/// Walk the slice `value[line_start..line_end]` char by char,
/// accumulating widths, and return the byte offset (within `value`)
/// where the click `target_x` falls. The "snap to nearest boundary"
/// rule is the standard `text input is text input` behaviour: if the
/// click is past the half-glyph mark, the cursor lands *after* the
/// glyph. `secure` toggles bullet substitution at measurement time so
/// the same logic works for password fields.
pub( super ) fn byte_offset_in_line(
canvas: &Canvas,
value: &str,
line_start: usize,
line_end: usize,
target_x: f32,
secure: bool,
font_size: f32,
) -> usize
{
let line = &value[line_start..line_end];
if target_x <= 0.0 { return line_start; }
let mut acc_w = 0.0f32;
let mut last_byte = line_start;
for ( ofs, ch ) in line.char_indices()
{
let glyph_str = if secure { "\u{2022}".to_string() } else { ch.to_string() };
let w = canvas.measure_text( &glyph_str, font_size );
if target_x < acc_w + w * 0.5
{
return line_start + ofs;
}
acc_w += w;
last_byte = line_start + ofs + ch.len_utf8();
}
last_byte.min( line_end )
}

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 );
}
}
}

View File

@@ -0,0 +1,702 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::TextEdit;
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg
{
Changed( String ),
Submitted,
}
fn new_te( value: &str ) -> TextEdit<Msg>
{
TextEdit::new( "placeholder".into(), value.into() )
}
// ── Construction ──────────────────────────────────────────────────────────
#[ test ]
fn new_places_cursor_at_end_of_initial_value()
{
let t = new_te( "hello" );
assert_eq!( t.cursor_pos, 5 );
assert_eq!( t.value, "hello" );
}
#[ test ]
fn new_with_empty_value_starts_with_zero_cursor()
{
let t = new_te( "" );
assert_eq!( t.cursor_pos, 0 );
assert!( t.value.is_empty() );
}
#[ test ]
fn new_with_multibyte_value_uses_byte_length_for_cursor()
{
// "café" is 5 bytes (c-a-f-é where é is 2 bytes in UTF-8).
let t = new_te( "café" );
assert_eq!( t.cursor_pos, 5 );
assert_eq!( t.value.len(), 5 );
}
// ── insert_str ────────────────────────────────────────────────────────────
#[ test ]
fn insert_str_at_end_appends_and_advances_cursor()
{
let mut t = new_te( "ab" );
assert_eq!( t.cursor_pos, 2 );
let _ = t.insert_str( "c" );
assert_eq!( t.value, "abc" );
assert_eq!( t.cursor_pos, 3 );
}
#[ test ]
fn insert_str_at_middle_inserts_and_advances_cursor_by_byte_len()
{
let mut t = new_te( "ab" );
t.cursor_pos = 1;
let _ = t.insert_str( "X" );
assert_eq!( t.value, "aXb" );
assert_eq!( t.cursor_pos, 2 );
}
#[ test ]
fn insert_str_multibyte_advances_cursor_by_utf8_byte_count()
{
let mut t = new_te( "" );
let _ = t.insert_str( "é" ); // 2 bytes
assert_eq!( t.value, "é" );
assert_eq!( t.cursor_pos, 2 );
let _ = t.insert_str( "🦀" ); // 4 bytes
assert_eq!( t.cursor_pos, 6 );
}
#[ test ]
fn insert_str_clamps_runaway_cursor()
{
// If cursor_pos is somehow past value.len() (defensive), insert should
// behave as "insert at end" rather than panic.
let mut t = new_te( "ab" );
t.cursor_pos = 99;
let _ = t.insert_str( "c" );
assert_eq!( t.value, "abc" );
// Cursor is clamped to new value length.
assert_eq!( t.cursor_pos, 3 );
}
#[ test ]
fn insert_str_returns_on_change_with_new_value()
{
let mut t = new_te( "ab" )
.on_change( |s| Msg::Changed( s ) );
let msg = t.insert_str( "c" );
assert_eq!( msg, Some( Msg::Changed( "abc".into() ) ) );
}
#[ test ]
fn insert_str_without_on_change_returns_none()
{
let mut t = new_te( "ab" );
assert_eq!( t.insert_str( "c" ), None );
}
// ── backspace ─────────────────────────────────────────────────────────────
#[ test ]
fn backspace_at_zero_cursor_is_noop()
{
let mut t = new_te( "abc" );
t.cursor_pos = 0;
assert_eq!( t.backspace(), None );
assert_eq!( t.value, "abc" );
assert_eq!( t.cursor_pos, 0 );
}
#[ test ]
fn backspace_at_end_removes_last_char()
{
let mut t = new_te( "abc" );
let _ = t.backspace();
assert_eq!( t.value, "ab" );
assert_eq!( t.cursor_pos, 2 );
}
#[ test ]
fn backspace_in_middle_removes_char_before_cursor()
{
let mut t = new_te( "abc" );
t.cursor_pos = 2;
let _ = t.backspace();
assert_eq!( t.value, "ac" );
assert_eq!( t.cursor_pos, 1 );
}
#[ test ]
fn backspace_handles_multibyte_acute()
{
// "é" is two bytes; backspacing must remove the whole codepoint.
let mut t = new_te( "café" );
let _ = t.backspace();
assert_eq!( t.value, "caf" );
assert_eq!( t.cursor_pos, 3 );
}
#[ test ]
fn backspace_handles_emoji_codepoint()
{
// Crab emoji is 4 bytes in UTF-8; cursor must back up by 4.
let mut t = new_te( "x🦀" );
assert_eq!( t.cursor_pos, 5 );
let _ = t.backspace();
assert_eq!( t.value, "x" );
assert_eq!( t.cursor_pos, 1 );
}
#[ test ]
fn backspace_returns_on_change_with_new_value()
{
let mut t = new_te( "ab" )
.on_change( |s| Msg::Changed( s ) );
let msg = t.backspace();
assert_eq!( msg, Some( Msg::Changed( "a".into() ) ) );
}
// ── display_text + secure ─────────────────────────────────────────────────
#[ test ]
fn display_text_plain_returns_value_verbatim()
{
let t = new_te( "hello" );
assert_eq!( t.display_text(), "hello" );
}
#[ test ]
fn display_text_secure_returns_bullets_one_per_codepoint()
{
// One bullet per codepoint, NOT per byte. "café" has 4 codepoints,
// so the masked text must be 4 bullets — not 5 (the byte length).
let t = new_te( "café" ).secure( true );
let masked = t.display_text();
assert_eq!( masked.chars().count(), 4 );
assert!( masked.chars().all( |c| c == '\u{2022}' ) );
}
#[ test ]
fn display_text_secure_with_emoji_one_bullet_per_codepoint()
{
// Crab emoji is one codepoint — exactly one bullet, even though it
// occupies four bytes in UTF-8.
let t = new_te( "🦀" ).secure( true );
assert_eq!( t.display_text().chars().count(), 1 );
}
#[ test ]
fn secure_mode_does_not_touch_underlying_value()
{
let t = new_te( "secret" ).secure( true );
assert_eq!( t.value, "secret" );
}
#[ test ]
fn secure_toggles_via_builder()
{
let t = new_te( "x" ).secure( true ).secure( false );
assert_eq!( t.display_text(), "x" );
}
// ── secure-mode credential zeroize ────────────────────────────────────────
#[ test ]
fn secure_flag_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
// The runtime takes a `WidgetHandlers` snapshot of every interactive
// widget once per frame. The `secure` flag must travel with the
// snapshot so the handler's own `Drop` (which mirrors `TextEdit`'s
// wipe-on-drop) knows whether to scrub on release.
let widget: Element<()> = TextEdit::new( "p".into(), "secret".into() )
.secure( true )
.into_element();
let h = widget.handlers();
match h
{
WidgetHandlers::TextEdit { secure, .. } => assert!( secure, "secure flag must propagate" ),
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn multiline_flag_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
let widget: Element<()> = TextEdit::new( "p".into(), "x".into() )
.multiline( true )
.into_element();
match widget.handlers()
{
WidgetHandlers::TextEdit { multiline, .. } => assert!( multiline, "multiline flag must propagate" ),
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn secure_overrides_multiline_in_widget_handlers()
{
use crate::widget::{ Element, WidgetHandlers };
// `is_multiline()` requires `!secure` — a secure password field
// must remain single-line even if the caller also asked for
// multiline. The handler snapshot reads `is_multiline()`, so
// `multiline` here must be `false`.
let widget: Element<()> = TextEdit::new( "p".into(), "x".into() )
.multiline( true )
.secure( true )
.into_element();
match widget.handlers()
{
WidgetHandlers::TextEdit { multiline, secure, .. } =>
{
assert!( secure, "secure must propagate" );
assert!( !multiline, "secure must override multiline" );
}
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn non_secure_flag_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
let widget: Element<()> = TextEdit::new( "p".into(), "x".into() )
.into_element();
match widget.handlers()
{
WidgetHandlers::TextEdit { secure, .. } => assert!( !secure, "default is non-secure" ),
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn secure_drop_zeros_underlying_string_buffer()
{
// Reach into the value's byte buffer right before the drop, run the
// drop logic by replacing the binding, and confirm the bytes that
// had been the credential are now zero. We cannot inspect the
// allocation after the actual drop (UB), so this test exercises the
// same path that `Drop::drop` runs internally.
let mut t: TextEdit<()> = TextEdit::new( "p".into(), "secret".into() ).secure( true );
assert_eq!( t.value, "secret" );
// Manually run the wipe the way Drop will; the next assertion checks
// the buffer is zeroed before the auto-drop releases it.
// SAFETY: same as the production wipe path above — overwriting
// every byte with zero leaves the String holding a sequence of NUL
// codepoints, which is valid UTF-8.
let bytes = unsafe { t.value.as_mut_vec() };
crate::secure_mem::secure_zero( bytes );
assert!( bytes.iter().all( |&b| b == 0 ) );
assert_eq!( bytes.len(), 6 );
}
#[ test ]
fn non_secure_text_edit_drop_is_a_noop_for_value()
{
// A non-secure widget must not pay the wipe cost. We cannot observe
// the absence of writes directly, but we can confirm that the
// underlying bytes still match the original content right up to
// the moment of drop.
let t: TextEdit<()> = TextEdit::new( "p".into(), "value".into() );
assert_eq!( t.value.as_bytes(), b"value" );
// Drop runs at end of scope without touching the bytes.
}
// ── on_submit / on_change wiring ──────────────────────────────────────────
#[ test ]
fn on_submit_stores_message_unchanged()
{
let t = new_te( "" ).on_submit( Msg::Submitted );
assert_eq!( t.on_submit, Some( Msg::Submitted ) );
}
// ── full edit roundtrip ───────────────────────────────────────────────────
#[ test ]
fn type_word_then_clear_with_backspace()
{
let mut t = new_te( "" );
for ch in "hola".chars()
{
let _ = t.insert_str( &ch.to_string() );
}
assert_eq!( t.value, "hola" );
assert_eq!( t.cursor_pos, 4 );
while t.cursor_pos > 0
{
let _ = t.backspace();
}
assert!( t.value.is_empty() );
assert_eq!( t.cursor_pos, 0 );
}
// ── multiline mode ───────────────────────────────────────────────────────
#[ test ]
fn multiline_default_off()
{
let t = new_te( "" );
assert!( !t.multiline );
assert!( !t.is_multiline() );
}
#[ test ]
fn multiline_builder_enables()
{
let t = new_te( "" ).multiline( true );
assert!( t.multiline );
assert!( t.is_multiline() );
}
#[ test ]
fn multiline_grows_preferred_height_with_rows()
{
use crate::render::Canvas;
let canvas = Canvas::new( 800, 600 );
let single = new_te( "" );
let ( _, h_single ) = single.preferred_size( 200.0, &canvas );
assert_eq!( h_single, super::theme::HEIGHT );
let multi = new_te( "" ).multiline( true ).rows( 5 );
let ( _, h_multi ) = multi.preferred_size( 200.0, &canvas );
assert!( h_multi > h_single, "multiline must claim more vertical space" );
let bigger = new_te( "" ).multiline( true ).rows( 10 );
let ( _, h_bigger ) = bigger.preferred_size( 200.0, &canvas );
assert!( h_bigger > h_multi, "more rows → taller box" );
}
#[ test ]
fn rows_clamps_zero_to_one()
{
let t = new_te( "" ).multiline( true ).rows( 0 );
assert_eq!( t.rows, 1 );
}
#[ test ]
fn secure_overrides_multiline_in_is_multiline_query()
{
// A `multiline + secure` configuration must remain single-line:
// passwords carrying a literal `\n` would defeat the password
// mode rendering and turn the credential boundary fuzzy.
let t: TextEdit<()> = TextEdit::new( "p".into(), "x".into() )
.multiline( true )
.secure( true );
assert!( t.multiline ); // raw flag preserved
assert!( !t.is_multiline() ); // effective mode is single-line
}
#[ test ]
fn insert_str_can_carry_newline_in_multiline_mode()
{
// The widget itself does not gate `\n` — that decision lives at
// dispatch time. We just confirm `\n` round-trips through
// insert_str + the value buffer untouched.
let mut t = new_te( "ab" ).multiline( true );
let _ = t.insert_str( "\n" );
assert_eq!( t.value, "ab\n" );
assert_eq!( t.cursor_pos, 3 );
let _ = t.insert_str( "c" );
assert_eq!( t.value, "ab\nc" );
}
// ── builders for the inline / picker variants ────────────────────────────
#[ test ]
fn defaults_for_new_inline_builders()
{
let t: TextEdit<()> = TextEdit::new( "p".into(), "v".into() );
assert_eq!( t.align, super::super::text::TextAlign::Left );
assert!( !t.borderless );
assert!( t.fixed_width.is_none() );
assert_eq!( t.font_size, super::theme::FONT_SIZE );
assert!( !t.select_on_focus );
}
#[ test ]
fn align_builder_sets_alignment()
{
use super::super::text::TextAlign;
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).align( TextAlign::Center );
assert_eq!( t.align, TextAlign::Center );
let t = t.align( TextAlign::Right );
assert_eq!( t.align, TextAlign::Right );
}
#[ test ]
fn borderless_builder_toggles_flag()
{
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).borderless( true );
assert!( t.borderless );
}
#[ test ]
fn fixed_width_builder_stores_value()
{
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 72.0 );
assert_eq!( t.fixed_width, Some( 72.0 ) );
}
#[ test ]
fn font_size_builder_clamps_to_at_least_one_pixel()
{
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( 28.0 );
assert_eq!( t.font_size, 28.0 );
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( -3.0 );
assert_eq!( t.font_size, 1.0 );
}
#[ test ]
fn select_on_focus_builder_toggles_flag()
{
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).select_on_focus( true );
assert!( t.select_on_focus );
}
#[ test ]
fn fixed_width_overrides_preferred_size_width()
{
let canvas = crate::render::Canvas::new( 1, 1 );
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 64.0 );
let ( w, _h ) = t.preferred_size( 1000.0, &canvas );
assert_eq!( w, 64.0 );
}
#[ test ]
fn fixed_width_capped_by_max_width()
{
let canvas = crate::render::Canvas::new( 1, 1 );
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 500.0 );
let ( w, _h ) = t.preferred_size( 200.0, &canvas );
// fixed_width is capped by the parent's available width to
// avoid overflowing tight rows.
assert_eq!( w, 200.0 );
}
#[ test ]
fn no_fixed_width_falls_back_to_max_width()
{
let canvas = crate::render::Canvas::new( 1, 1 );
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() );
let ( w, _h ) = t.preferred_size( 320.0, &canvas );
assert_eq!( w, 320.0 );
}
#[ test ]
fn align_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
use super::super::text::TextAlign;
let widget: Element<()> = TextEdit::new( "".into(), "".into() )
.align( TextAlign::Center )
.into_element();
match widget.handlers()
{
WidgetHandlers::TextEdit { align, .. } => assert_eq!( align, TextAlign::Center ),
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn font_size_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
let widget: Element<()> = TextEdit::new( "".into(), "".into() )
.font_size( 28.0 )
.into_element();
match widget.handlers()
{
WidgetHandlers::TextEdit { font_size, .. } => assert!( ( font_size - 28.0 ).abs() < 1e-6 ),
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn select_on_focus_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
let widget: Element<()> = TextEdit::new( "".into(), "".into() )
.select_on_focus( true )
.into_element();
match widget.handlers()
{
WidgetHandlers::TextEdit { select_on_focus, .. } => assert!( select_on_focus ),
_ => panic!( "expected TextEdit handler" ),
}
}
// ── password_toggle ───────────────────────────────────────────────────────
#[ derive( Clone, Debug, PartialEq, Eq ) ] enum ToggleMsg { Flip }
#[ test ]
fn password_toggle_default_is_none()
{
let t = TextEdit::<ToggleMsg>::new( "".into(), "".into() );
assert!( t.password_toggle.is_none() );
}
#[ test ]
fn password_toggle_builder_records_visible_and_msg()
{
let t = TextEdit::<ToggleMsg>::new( "".into(), "".into() )
.password_toggle( true, ToggleMsg::Flip );
assert_eq!( t.password_toggle, Some( ( true, ToggleMsg::Flip ) ) );
}
#[ test ]
fn effective_secure_falls_back_to_secure_field_when_no_toggle()
{
let plain = TextEdit::<ToggleMsg>::new( "".into(), "".into() );
let secret = TextEdit::<ToggleMsg>::new( "".into(), "".into() ).secure( true );
assert!( !plain.effective_secure() );
assert!( secret.effective_secure() );
}
#[ test ]
fn password_toggle_overrides_secure_field()
{
// When the toggle is set, its `visible` controls bullets
// regardless of the explicit `secure` flag.
let visible_with_secure = TextEdit::<ToggleMsg>::new( "".into(), "".into() )
.secure( true )
.password_toggle( true, ToggleMsg::Flip );
let hidden_no_secure = TextEdit::<ToggleMsg>::new( "".into(), "".into() )
.password_toggle( false, ToggleMsg::Flip );
assert!( !visible_with_secure.effective_secure() );
assert!( hidden_no_secure.effective_secure() );
}
#[ test ]
fn password_toggle_hidden_substitutes_bullets_in_display_text()
{
let t = TextEdit::<ToggleMsg>::new( "".into(), "abc".into() )
.password_toggle( false, ToggleMsg::Flip );
assert_eq!( t.display_text(), "•••" );
}
#[ test ]
fn password_toggle_visible_returns_value_verbatim()
{
let t = TextEdit::<ToggleMsg>::new( "".into(), "abc".into() )
.password_toggle( true, ToggleMsg::Flip );
assert_eq!( t.display_text(), "abc" );
}
#[ test ]
fn password_toggle_collapses_multiline_to_single_line()
{
// `is_multiline()` follows `effective_secure` — a hidden
// password collapses to single-line even if `multiline(true)`
// was set, matching the behaviour of an explicit `secure`.
let t = TextEdit::<ToggleMsg>::new( "".into(), "".into() )
.multiline( true )
.password_toggle( false, ToggleMsg::Flip );
assert!( !t.is_multiline() );
}
#[ test ]
fn password_toggle_msg_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
let widget: Element<ToggleMsg> = TextEdit::new( "".into(), "".into() )
.password_toggle( false, ToggleMsg::Flip )
.into_element();
// `handlers()` returns a value of `WidgetHandlers<Msg>`, which
// implements `Drop` (wipe-on-drop for secure fields), so we
// cannot destructure it by value-move. Bind first, then match
// on a reference.
let h = widget.handlers();
match &h
{
WidgetHandlers::TextEdit { password_toggle_msg, secure, .. } =>
{
assert_eq!( password_toggle_msg.as_ref(), Some( &ToggleMsg::Flip ) );
// Snapshot's `secure` is true even though `visible=false`
// because the field carries a password regardless.
assert!( *secure );
}
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn password_toggle_msg_none_when_no_toggle_configured()
{
use crate::widget::{ Element, WidgetHandlers };
let widget: Element<ToggleMsg> = TextEdit::new( "".into(), "".into() )
.into_element();
let h = widget.handlers();
match &h
{
WidgetHandlers::TextEdit { password_toggle_msg, .. } =>
{
assert!( password_toggle_msg.is_none() );
}
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn password_toggle_hit_zone_sits_at_right_edge()
{
use crate::types::{ Point, Rect };
let rect = Rect { x: 100.0, y: 50.0, width: 240.0, height: 48.0 };
let zone = super::password_toggle_hit_zone( rect );
// Right edge of the zone matches the field's right edge.
assert!( ( ( zone.x + zone.width ) - ( rect.x + rect.width ) ).abs() < 0.01 );
// Wide enough to cover the icon + slop on both sides + half pad.
assert!( zone.width >= super::theme::PASSWORD_TOGGLE_SIZE );
// A point on the centre-right hits the zone.
let p = Point { x: rect.x + rect.width - 8.0, y: rect.y + rect.height / 2.0 };
assert!( zone.contains( p ) );
// A point well to the left of the zone misses it.
let p = Point { x: rect.x + 10.0, y: rect.y + rect.height / 2.0 };
assert!( !zone.contains( p ) );
}
#[ test ]
fn password_toggle_hit_zone_clamped_when_field_narrower_than_icon()
{
use crate::types::Rect;
let rect = Rect { x: 0.0, y: 0.0, width: 4.0, height: 24.0 };
let zone = super::password_toggle_hit_zone( rect );
// Zone never extends past the field's right edge.
assert!( zone.x + zone.width <= rect.x + rect.width + f32::EPSILON );
}
#[ test ]
fn password_toggle_drop_zeros_underlying_string_buffer()
{
// Same wipe-on-drop guarantee `secure( true )` gives — a
// `password_toggle` field is sensitive even when currently
// visible, so the buffer must be scrubbed on drop.
let t = TextEdit::<ToggleMsg>::new( "".into(), "shhh".into() )
.password_toggle( true, ToggleMsg::Flip );
let ptr = t.value.as_ptr();
let len = t.value.len();
drop( t );
// SAFETY: `String` deallocates the buffer in its `Drop`, but
// the bytes at `ptr..ptr+len` were zeroed *before* the
// allocator was notified — reading them now is a use-after-
// free, so we don't. Instead we simply assert that the test
// reaches this line without panicking; the `secure_zero`
// path runs unconditionally for password_toggle fields.
let _ = ( ptr, len );
}

View File

@@ -0,0 +1,43 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Color;
pub fn bg() -> Color { crate::theme::palette().surface_alt }
pub fn border() -> Color { crate::theme::palette().divider }
pub fn text() -> Color { crate::theme::palette().text_primary }
pub fn placeholder() -> Color { crate::theme::palette().text_secondary }
pub fn focus_border() -> Color { crate::theme::palette().text_primary }
pub fn cursor() -> Color { crate::theme::palette().text_primary }
/// Selection highlight — accent colour at low opacity so the text
/// underneath stays readable.
pub fn selection() -> Color
{
let a = crate::theme::palette().accent;
Color::rgba( a.r, a.g, a.b, 0.35 )
}
pub const BORDER_W: f32 = 1.5;
pub const FOCUS_BORDER_W: f32 = 2.0;
pub const HEIGHT: f32 = 48.0;
pub const RADIUS: f32 = 100.0;
pub const FONT_SIZE: f32 = 16.0;
pub const PAD_H: f32 = 20.0;
/// Vertical padding inside multiline boxes — the single-line variant
/// centres the text vertically in `HEIGHT`, so it doesn't need this.
pub const PAD_V_MULTI: f32 = 12.0;
/// Line height multiplier on top of [`FONT_SIZE`] when laying out
/// multiline content. 1.4 leaves enough room above and below each
/// line that adjacent rows do not visually crowd each other.
pub const LINE_H_MULT: f32 = 1.4;
/// Default visible row count for a [`super::TextEdit`] in multiline mode.
pub const ROWS_DEFAULT: u32 = 5;
/// Corner radius applied to multiline boxes — the pill shape used by
/// the single-line variant looks wrong at multi-row heights.
pub const RADIUS_MULTI: f32 = 12.0;
/// Side length, in logical pixels, of the
/// [`super::TextEdit::password_toggle`] eye icon.
pub const PASSWORD_TOGGLE_SIZE: f32 = 20.0;
/// Extra horizontal slop on each side of the eye icon's hit zone
/// to make the toggle finger-friendly even on touch panels.
pub const PASSWORD_TOGGLE_SLOP: f32 = 4.0;

View File

@@ -0,0 +1,115 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
/// One visual row of a wrapped multiline TextEdit. `start..end` is a
/// byte range inside the value; the renderer draws those bytes on a
/// single visual row, even if the underlying logical line (the run
/// between two `\n` characters) spans several rows. Soft wraps do
/// not mutate the buffer — the value stays a flat string with hard
/// breaks; only the rendering / hit-testing model treats wraps as
/// row boundaries.
#[ derive( Clone, Copy, Debug ) ]
pub( crate ) struct VisualLine
{
pub start: usize,
pub end: usize,
}
/// Wrap `value` into a list of visual lines that each fit within
/// `inner_width` when rendered at `font_size`. Hard `\n` breaks are
/// always row boundaries; long logical lines are additionally split
/// at the last whitespace before the row would overflow, falling
/// back to a per-character break inside a single oversized word.
///
/// O(N) per call where N is `value.len()` — `draw_multiline` runs
/// it once per frame, which is fine for the kilobyte-scale buffers a
/// `text_edit` is meant to hold (anything larger should use a
/// dedicated text-area widget with cached layout).
pub( crate ) fn compute_visual_lines(
canvas: &Canvas,
value: &str,
inner_width: f32,
font_size: f32,
) -> Vec<VisualLine>
{
let mut out = Vec::new();
if value.is_empty()
{
out.push( VisualLine { start: 0, end: 0 } );
return out;
}
let max_w = inner_width.max( 1.0 );
let mut byte_pos = 0usize;
for logical_line in value.split( '\n' )
{
let line_start = byte_pos;
let line_end = byte_pos + logical_line.len();
if line_start == line_end
{
// Empty logical line still occupies a row.
out.push( VisualLine { start: line_start, end: line_end } );
} else {
wrap_logical_line( canvas, value, line_start, line_end, max_w, font_size, &mut out );
}
byte_pos = line_end + 1; // step past the '\n'
}
out
}
fn wrap_logical_line(
canvas: &Canvas,
value: &str,
start: usize,
end: usize,
max_width: f32,
font_size: f32,
out: &mut Vec<VisualLine>,
)
{
let mut cur = start;
while cur < end
{
let segment = &value[cur..end];
let break_at = find_wrap_offset( canvas, segment, max_width, font_size );
// Always advance at least one byte so a degenerate measure
// (zero-width font, ridiculously narrow rect) still
// terminates instead of looping forever.
let raw_next = cur + break_at;
let next = raw_next.max( cur + 1 ).min( end );
out.push( VisualLine { start: cur, end: next } );
cur = next;
}
}
/// Walk `segment` left-to-right accumulating glyph widths and return
/// the byte offset (within `segment`) where the visual line should
/// break. Prefers a break *after* the most recent whitespace; falls
/// back to mid-character break if a single word is wider than
/// `max_width`.
fn find_wrap_offset(
canvas: &Canvas,
segment: &str,
max_width: f32,
font_size: f32,
) -> usize
{
let mut acc_w = 0.0f32;
let mut last_break_after: Option<usize> = None;
for ( i, ch ) in segment.char_indices()
{
let glyph = ch.to_string();
let w = canvas.measure_text( &glyph, font_size );
if acc_w + w > max_width && i > 0
{
return last_break_after.unwrap_or( i );
}
acc_w += w;
if ch == ' ' || ch == '\t'
{
last_break_after = Some( i + ch.len_utf8() );
}
}
segment.len()
}