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:
108
src/widget/text_edit/cursor.rs
Normal file
108
src/widget/text_edit/cursor.rs
Normal 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() )
|
||||
}
|
||||
397
src/widget/text_edit/draw.rs
Normal file
397
src/widget/text_edit/draw.rs
Normal 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
203
src/widget/text_edit/hit_test.rs
Normal file
203
src/widget/text_edit/hit_test.rs
Normal 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
513
src/widget/text_edit/mod.rs
Normal 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
702
src/widget/text_edit/tests.rs
Normal file
702
src/widget/text_edit/tests.rs
Normal 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 );
|
||||
}
|
||||
43
src/widget/text_edit/theme.rs
Normal file
43
src/widget/text_edit/theme.rs
Normal 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;
|
||||
115
src/widget/text_edit/wrapping.rs
Normal file
115
src/widget/text_edit/wrapping.rs
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user