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