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:
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 )
|
||||
}
|
||||
Reference in New Issue
Block a user