// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. use crate::app::App; use crate::event_loop::app_data::AppData; use crate::event_loop::surface::SurfaceFocus; use crate::tree::find_handlers; use crate::types::Point; use crate::widget::WidgetHandlers; /// Compute the byte range of the "word" surrounding `byte_offset` in /// `value`. A word is a maximal run of alphanumeric / underscore /// chars; whitespace and punctuation count as separators. When /// `byte_offset` falls *between* two non-word chars, both bounds /// collapse to that offset (zero-width selection — the caller is /// expected to either accept that or fall back to a click-position /// cursor). fn word_bounds_at( value: &str, byte_offset: usize ) -> ( usize, usize ) { if value.is_empty() { return ( 0, 0 ); } let bo = byte_offset.min( value.len() ); let is_word = | c: char | c.is_alphanumeric() || c == '_'; // Walk left from `bo` until we cross a non-word boundary. let mut start = bo; { let mut iter = value[..bo].char_indices().rev(); while let Some( ( i, c ) ) = iter.next() { if is_word( c ) { start = i; } else { break; } } } // Walk right from `bo` until the same. let mut end = bo; for ( i, c ) in value[bo..].char_indices() { if is_word( c ) { end = bo + i + c.len_utf8(); } else { break; } } ( start, end ) } impl AppData { /// `true` when this press should be treated as the second click /// of a double-click pair. Looks at the timestamp + position of /// the previous press and compares against a 400 ms / 6 px /// threshold (matching the GTK / Cocoa defaults). Always updates /// the snapshot so the next press can pair with this one. pub( crate ) fn note_press_for_double_click( &mut self, pos: Point ) -> bool { const WINDOW_MS: u128 = 400; const RADIUS: f32 = 6.0; let now = std::time::Instant::now(); let is_double = match ( self.last_press_time, self.last_press_pos ) { ( Some( t ), Some( p ) ) => { let elapsed = now.duration_since( t ).as_millis(); let dx = pos.x - p.x; let dy = pos.y - p.y; elapsed <= WINDOW_MS && dx.hypot( dy ) <= RADIUS } _ => false, }; if is_double { // Consume the snapshot so a third quick click is not // re-paired with the same first click. self.last_press_time = None; self.last_press_pos = None; } else { self.last_press_time = Some( now ); self.last_press_pos = Some( pos ); } is_double } /// Select the word under the press. The "word" is the maximal /// run of alphanumeric / underscore chars surrounding the byte /// offset that `pos` falls on; everything else (whitespace, /// punctuation) counts as a separator. Empty value → no-op. pub( crate ) fn handle_text_select_word( &mut self, focus: SurfaceFocus, idx: usize, pos: Point ) { // `select_on_focus` fields are short-form: the whole value is // already the natural selection unit, so a double-click does // not need to add a word-bound selection on top. let select_on_focus = matches!( find_handlers( &self.surface( focus ).frame.widget_rects, idx ), Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), ); if select_on_focus { return; } let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx ) { Some( v ) => v, None => return, }; if value.is_empty() { return; } let cursor_pos = self.surface( focus ).frame.cursor_state.get( &idx ).copied().unwrap_or( 0 ); let click_byte = { let ss = self.surface( focus ); let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return }; crate::widget::text_edit::byte_offset_at( canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size, ) }; let ( start, end ) = word_bounds_at( &value, click_byte ); let ss = self.surface_mut( focus ); ss.frame.selection_anchor.insert( idx, start ); ss.frame.cursor_state.insert( idx, end ); ss.request_redraw(); } /// Position the text cursor at the click and start a fresh /// selection (anchor = cursor). Called from the pointer / touch /// press handler after focus has been moved to the TextEdit. pub( crate ) fn handle_text_pointer_down( &mut self, focus: SurfaceFocus, idx: usize, pos: Point ) { // Fields with `select_on_focus` (numeric pickers, short-form // inputs) keep the whole-value selection that `set_focus` // just installed — clicking is the *focus* gesture and the // next keystroke is meant to replace, not insert. Without // this guard the click-to-position below would collapse the // selection right after `set_focus` produced it. let select_on_focus = matches!( find_handlers( &self.surface( focus ).frame.widget_rects, idx ), Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), ); if select_on_focus { return; } let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx ) { Some( v ) => v, None => return, }; let cursor_pos = self.surface( focus ).frame.cursor_state.get( &idx ).copied().unwrap_or( 0 ); let byte_offset = { let ss = self.surface( focus ); let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return }; crate::widget::text_edit::byte_offset_at( canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size, ) }; let ss = self.surface_mut( focus ); ss.frame.cursor_state.insert( idx, byte_offset ); ss.frame.selection_anchor.insert( idx, byte_offset ); ss.request_redraw(); } /// Extend the selection by moving the cursor to the new pointer /// position. Called from the pointer / touch motion handler while /// a press is active and the original press landed on a TextEdit. /// The selection anchor (set on press) stays put. pub( crate ) fn handle_text_pointer_drag( &mut self, focus: SurfaceFocus, idx: usize, pos: Point ) { // Same `select_on_focus` guard as the press-down path: the // short-form field stays whole-selected even if the user // drags the pointer over the digits. let select_on_focus = matches!( find_handlers( &self.surface( focus ).frame.widget_rects, idx ), Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), ); if select_on_focus { return; } let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx ) { Some( v ) => v, None => return, }; let cursor_pos = self.surface( focus ).frame.cursor_state.get( &idx ).copied().unwrap_or( 0 ); let byte_offset = { let ss = self.surface( focus ); let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return }; crate::widget::text_edit::byte_offset_at( canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size, ) }; let ss = self.surface_mut( focus ); ss.frame.cursor_state.insert( idx, byte_offset ); ss.request_redraw(); } /// Collapse any active selection on the focused text input to its /// cursor position. Returns `true` when a collapse happened so /// the caller (Esc handler) can short-circuit other behaviour. pub( crate ) fn collapse_selection_if_any( &mut self, focus: SurfaceFocus ) -> bool { let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return false }; let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied(); let anchor = self.surface( focus ).frame.selection_anchor.get( &idx ).copied(); let ( c, a ) = match ( cursor, anchor ) { ( Some( c ), Some( a ) ) if c != a => ( c, a ), _ => return false, }; let _ = a; let ss = self.surface_mut( focus ); ss.frame.selection_anchor.insert( idx, c ); ss.request_redraw(); true } /// Select the entire value of the focused text input. Used by /// Ctrl+A. pub( crate ) fn handle_select_all( &mut self, focus: SurfaceFocus ) { let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return }; let ss = self.surface_mut( focus ); *ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = value.len(); *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = 0; ss.request_redraw(); } /// Snapshot the currently-selected text for the focused widget. /// Returns `None` when no text input is focused or the selection /// is empty. pub( crate ) fn focused_selection_text( &self, focus: SurfaceFocus ) -> Option { let ( idx, value ) = self.focused_text_value( focus )?; let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied() .unwrap_or( value.len() ).min( value.len() ); let anchor = self.surface( focus ).frame.selection_anchor.get( &idx ).copied() .unwrap_or( cursor ).min( value.len() ); if anchor == cursor { return None; } let s = cursor.min( anchor ); let e = cursor.max( anchor ); Some( value[s..e].to_string() ) } /// Delete the current selection (if non-empty), updating the /// pending text value and cursor / anchor. Returns the resulting /// string when a deletion happened so the caller can dispatch the /// `on_change` message; returns `None` when there was no selection /// to delete. pub( crate ) fn delete_selection( &mut self, focus: SurfaceFocus ) -> Option { let ( idx, value ) = self.focused_text_value( focus )?; let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied() .unwrap_or( value.len() ).min( value.len() ); let anchor = self.surface( focus ).frame.selection_anchor.get( &idx ).copied() .unwrap_or( cursor ).min( value.len() ); if anchor == cursor { return None; } let s = cursor.min( anchor ); let e = cursor.max( anchor ); let mut new_value = value; new_value.replace_range( s..e, "" ); // `select_on_focus` fields keep the cursor at the end of the // post-update displayed value via the `usize::MAX` sentinel — // see `handle_text_insert` for the reasoning. let select_on_focus = matches!( find_handlers( &self.surface( focus ).frame.widget_rects, idx ), Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), ); let next_cursor = if select_on_focus { usize::MAX } else { s }; let ss = self.surface_mut( focus ); *ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor; *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor; ss.pending_text_values.insert( idx, new_value.clone() ); ss.request_redraw(); Some( new_value ) } }