Files
ltk/src/widget/text_edit/hit_test.rs
Pedro M. de Echanove Pasquin 4aa3480b64 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.
2026-05-15 23:46:56 +02:00

204 lines
7.1 KiB
Rust

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