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.
204 lines
7.1 KiB
Rust
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 )
|
|
}
|