// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. 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 ) }