ltk/widget/text_edit/hit_test.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207
// 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::metrics::single_line_pad_h;
use super::tabs::{ next_stop, tab_width };
use super::TextFont;
use super::scroll::{ TextScroll, Window };
/// 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` and `scroll` are the runtime's, so the
/// multiline branch sees the same lines the most recent
/// [`super::TextEdit::draw`] call showed.
pub( crate ) fn byte_offset_at(
canvas: &Canvas,
rect: Rect,
pos: Point,
value: &str,
multiline: bool,
secure: bool,
cursor_pos: usize,
scroll: TextScroll,
align: crate::widget::text::TextAlign,
font: &TextFont,
pad_h: Option<f32>,
) -> usize
{
if value.is_empty() { return 0; }
if multiline && !secure
{
let window = Window::shown( canvas, rect, value, font, cursor_pos, scroll );
let m = window.metrics;
let total_lines = window.lines.len();
let visual_idx = ( ( pos.y - rect.y - m.pad_v ) / m.line_h )
.max( 0.0 ) as usize;
let target_idx = ( window.first_line + visual_idx ).min( total_lines.saturating_sub( 1 ) );
let vl = window.lines[ target_idx ];
byte_offset_in_line( canvas, font, value, vl.start, vl.end, pos.x - rect.x - m.pad_h, false, true, m.font_size )
} else {
// Resolve the font-size sentinel (0.0 / negative → widget-scaling mode)
// so a click maps against the same size the renderer drew with.
let font_size = super::resolve_font_size( canvas, font.size );
// 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, pad_h );
let align_x = single_line_align_offset( canvas, rect, value, secure, align, font_size, pad_h );
byte_offset_in_line(
canvas, &TextFont::default(), value, 0, value.len(),
pos.x - rect.x - single_line_pad_h( rect, pad_h ) - align_x + scroll_x,
secure,
false,
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,
pad_h: Option<f32>,
) -> f32
{
let inner_width = ( rect.width - single_line_pad_h( rect, 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,
pad_h: Option<f32>,
) -> f32
{
use crate::widget::text::TextAlign;
if matches!( align, TextAlign::Left ) { return 0.0; }
let inner_width = ( rect.width - single_line_pad_h( rect, 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; `tabs` has a tab reach
/// the next stop, as a multiline field draws it.
#[ allow( clippy::too_many_arguments ) ]
pub( super ) fn byte_offset_in_line(
canvas: &Canvas,
font: &TextFont,
value: &str,
line_start: usize,
line_end: usize,
target_x: f32,
secure: bool,
tabs: bool,
font_size: f32,
) -> usize
{
let line = &value[line_start..line_end];
if target_x <= 0.0 { return line_start; }
let tab_w = if tabs { tab_width( canvas, font, font_size ) } else { 0.0 };
let mut acc_w = 0.0f32;
let mut last_byte = line_start;
for ( ofs, ch ) in line.char_indices()
{
let w = if secure
{
font.measure_char( canvas, '\u{2022}', font_size )
} else if tabs && ch == '\t' {
next_stop( acc_w, tab_w ) - acc_w
} else {
font.measure_char( canvas, ch, 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 )
}