tessera_ui_basic_components/
text_edit_core.rs

1//! Core module for text editing logic and state management in Tessera UI.
2//!
3//! This module provides the foundational structures and functions for building text editing components,
4//! including text buffer management, selection and cursor handling, rendering logic, and keyboard event mapping.
5//! It is designed to be shared across UI components via `Arc<RwLock<TextEditorState>>`, enabling consistent
6//! and efficient text editing experiences.
7//!
8//! Typical use cases include single-line and multi-line text editors, input fields, and any UI element
9//! requiring advanced text manipulation, selection, and IME support.
10//!
11//! The module integrates with the Tessera component system and rendering pipelines, supporting selection
12//! highlighting, cursor blinking, clipboard operations, and extensible keyboard shortcuts.
13//!
14//! Most applications should interact with [`TextEditorState`] for state management and [`text_edit_core()`]
15//! for rendering and layout within a component tree.
16
17mod cursor;
18
19use std::{sync::Arc, time::Instant};
20
21use glyphon::Edit;
22use parking_lot::RwLock;
23use tessera_ui::{
24    Clipboard, Color, ComputedData, DimensionValue, Dp, Px, PxPosition, focus_state::Focus, winit,
25};
26use tessera_ui_macros::tessera;
27use unicode_segmentation::UnicodeSegmentation;
28
29use crate::{
30    pipelines::{TextCommand, TextConstraint, TextData, write_font_system},
31    selection_highlight_rect::selection_highlight_rect,
32};
33
34/// Definition of a rectangular selection highlight
35#[derive(Clone, Debug)]
36/// Defines a rectangular region for text selection highlighting.
37///
38/// Used internally to represent the geometry of a selection highlight in pixel coordinates.
39pub struct RectDef {
40    /// The x-coordinate (in pixels) of the rectangle's top-left corner.
41    pub x: Px,
42    /// The y-coordinate (in pixels) of the rectangle's top-left corner.
43    pub y: Px,
44    /// The width (in pixels) of the rectangle.
45    pub width: Px,
46    /// The height (in pixels) of the rectangle.
47    pub height: Px,
48}
49
50/// Types of mouse clicks
51#[derive(Debug, Clone, Copy, PartialEq)]
52/// Represents the type of mouse click detected in the editor.
53///
54/// Used for distinguishing between single, double, and triple click actions.
55pub enum ClickType {
56    /// A single mouse click.
57    Single,
58    /// A double mouse click.
59    Double,
60    /// A triple mouse click.
61    Triple,
62}
63
64/// Core text editing state, shared between components
65/// Core state for text editing, including content, selection, cursor, and interaction state.
66///
67/// This struct manages the text buffer, selection, cursor position, focus, and user interaction state.
68/// It is designed to be shared between UI components via an `Arc<RwLock<TextEditorState>>`.
69///
70/// # Example
71/// ```
72/// use std::sync::Arc;
73/// use parking_lot::RwLock;
74/// use tessera_ui::Dp;
75/// use tessera_ui_basic_components::text_edit_core::{TextEditorState, text_edit_core};
76///
77/// let state = Arc::new(RwLock::new(TextEditorState::new(Dp(16.0), None)));
78/// // Use `text_edit_core(state.clone())` inside your component tree.
79/// ```
80pub struct TextEditorState {
81    line_height: Px,
82    pub(crate) editor: glyphon::Editor<'static>,
83    bink_timer: Instant,
84    focus_handler: Focus,
85    pub(crate) selection_color: Color,
86    pub(crate) current_selection_rects: Vec<RectDef>,
87    // Click tracking for double/triple click detection
88    last_click_time: Option<Instant>,
89    last_click_position: Option<PxPosition>,
90    click_count: u32,
91    is_dragging: bool,
92    // For IME
93    pub(crate) preedit_string: Option<String>,
94}
95
96impl TextEditorState {
97    /// Creates a new `TextEditorState` with the given font size and optional line height.
98    ///
99    /// # Arguments
100    ///
101    /// * `size` - Font size in Dp.
102    /// * `line_height` - Optional line height in Dp. If `None`, uses 1.2x the font size.
103    ///
104    /// # Example
105    /// ```
106    /// use tessera_ui::Dp;
107    /// use tessera_ui_basic_components::text_edit_core::TextEditorState;
108    /// let state = TextEditorState::new(Dp(16.0), None);
109    /// ```
110    pub fn new(size: Dp, line_height: Option<Dp>) -> Self {
111        Self::with_selection_color(size, line_height, Color::new(0.5, 0.7, 1.0, 0.4))
112    }
113
114    /// Creates a new `TextEditorState` with a custom selection highlight color.
115    ///
116    /// # Arguments
117    ///
118    /// * `size` - Font size in Dp.
119    /// * `line_height` - Optional line height in Dp.
120    /// * `selection_color` - Color used for selection highlight.
121    pub fn with_selection_color(size: Dp, line_height: Option<Dp>, selection_color: Color) -> Self {
122        let final_line_height = line_height.unwrap_or(Dp(size.0 * 1.2));
123        let line_height_px: Px = final_line_height.into();
124        let mut buffer = glyphon::Buffer::new(
125            &mut write_font_system(),
126            glyphon::Metrics::new(size.to_pixels_f32(), line_height_px.to_f32()),
127        );
128        buffer.set_wrap(&mut write_font_system(), glyphon::Wrap::Glyph);
129        let editor = glyphon::Editor::new(buffer);
130        Self {
131            line_height: line_height_px,
132            editor,
133            bink_timer: Instant::now(),
134            focus_handler: Focus::new(),
135            selection_color,
136            current_selection_rects: Vec::new(),
137            last_click_time: None,
138            last_click_position: None,
139            click_count: 0,
140            is_dragging: false,
141            preedit_string: None,
142        }
143    }
144
145    /// Returns the line height in pixels.
146    pub fn line_height(&self) -> Px {
147        self.line_height
148    }
149
150    /// Returns the current text buffer as `TextData`, applying the given layout constraints.
151    ///
152    /// # Arguments
153    ///
154    /// * `constraint` - Layout constraints for text rendering.
155    pub fn text_data(&mut self, constraint: TextConstraint) -> TextData {
156        self.editor.with_buffer_mut(|buffer| {
157            buffer.set_size(
158                &mut write_font_system(),
159                constraint.max_width,
160                constraint.max_height,
161            );
162            buffer.shape_until_scroll(&mut write_font_system(), false);
163        });
164
165        let text_buffer = match self.editor.buffer_ref() {
166            glyphon::cosmic_text::BufferRef::Owned(buffer) => buffer.clone(),
167            glyphon::cosmic_text::BufferRef::Borrowed(buffer) => (**buffer).to_owned(),
168            glyphon::cosmic_text::BufferRef::Arc(buffer) => (**buffer).clone(),
169        };
170
171        TextData::from_buffer(text_buffer)
172    }
173
174    /// Returns a reference to the internal focus handler.
175    pub fn focus_handler(&self) -> &Focus {
176        &self.focus_handler
177    }
178
179    /// Returns a mutable reference to the internal focus handler.
180    pub fn focus_handler_mut(&mut self) -> &mut Focus {
181        &mut self.focus_handler
182    }
183
184    /// Returns a reference to the underlying `glyphon::Editor`.
185    pub fn editor(&self) -> &glyphon::Editor<'static> {
186        &self.editor
187    }
188
189    /// Returns a mutable reference to the underlying `glyphon::Editor`.
190    pub fn editor_mut(&mut self) -> &mut glyphon::Editor<'static> {
191        &mut self.editor
192    }
193
194    /// Returns the current blink timer instant (for cursor blinking).
195    pub fn bink_timer(&self) -> Instant {
196        self.bink_timer
197    }
198
199    /// Resets the blink timer to the current instant.
200    pub fn update_bink_timer(&mut self) {
201        self.bink_timer = Instant::now();
202    }
203
204    /// Returns the current selection highlight color.
205    pub fn selection_color(&self) -> Color {
206        self.selection_color
207    }
208
209    /// Returns a reference to the current selection rectangles.
210    pub fn current_selection_rects(&self) -> &Vec<RectDef> {
211        &self.current_selection_rects
212    }
213
214    /// Sets the selection highlight color.
215    ///
216    /// # Arguments
217    ///
218    /// * `color` - The new selection color.
219    pub fn set_selection_color(&mut self, color: Color) {
220        self.selection_color = color;
221    }
222
223    /// Handles a mouse click event and determines the click type (single, double, triple).
224    ///
225    /// Used for text selection and word/line selection logic.
226    ///
227    /// # Arguments
228    ///
229    /// * `position` - The position of the click in pixels.
230    /// * `timestamp` - The time the click occurred.
231    ///
232    /// # Returns
233    ///
234    /// The detected [`ClickType`].
235    pub fn handle_click(&mut self, position: PxPosition, timestamp: Instant) -> ClickType {
236        const DOUBLE_CLICK_TIME_MS: u128 = 500; // 500ms for double click
237        const CLICK_DISTANCE_THRESHOLD: Px = Px(5); // 5 pixels tolerance for position
238
239        let click_type = if let (Some(last_time), Some(last_pos)) =
240            (self.last_click_time, self.last_click_position)
241        {
242            let time_diff = timestamp.duration_since(last_time).as_millis();
243            let distance = (position.x - last_pos.x).abs() + (position.y - last_pos.y).abs();
244
245            if time_diff <= DOUBLE_CLICK_TIME_MS && distance <= CLICK_DISTANCE_THRESHOLD.abs() {
246                self.click_count += 1;
247                match self.click_count {
248                    2 => ClickType::Double,
249                    3 => {
250                        self.click_count = 0; // Reset after triple click
251                        ClickType::Triple
252                    }
253                    _ => ClickType::Single,
254                }
255            } else {
256                self.click_count = 1;
257                ClickType::Single
258            }
259        } else {
260            self.click_count = 1;
261            ClickType::Single
262        };
263
264        self.last_click_time = Some(timestamp);
265        self.last_click_position = Some(position);
266        self.is_dragging = false;
267
268        click_type
269    }
270
271    /// Starts a drag operation (for text selection).
272    pub fn start_drag(&mut self) {
273        self.is_dragging = true;
274    }
275
276    /// Returns `true` if a drag operation is in progress.
277    pub fn is_dragging(&self) -> bool {
278        self.is_dragging
279    }
280
281    /// Stops the current drag operation.
282    pub fn stop_drag(&mut self) {
283        self.is_dragging = false;
284    }
285
286    /// Returns the last click position, if any.
287    pub fn last_click_position(&self) -> Option<PxPosition> {
288        self.last_click_position
289    }
290
291    /// Updates the last click position (used for drag tracking).
292    ///
293    /// # Arguments
294    ///
295    /// * `position` - The new last click position.
296    pub fn update_last_click_position(&mut self, position: PxPosition) {
297        self.last_click_position = Some(position);
298    }
299}
300
301#[tessera]
302/// Core text editing component for rendering text, selection, and cursor.
303///
304/// This component is responsible for rendering the text buffer, selection highlights, and cursor.
305/// It does not handle user events directly; instead, it is intended to be used inside a container
306/// that manages user interaction and passes state updates via `TextEditorState`.
307///
308/// # Arguments
309///
310/// * `state` - Shared state for the text editor, typically wrapped in `Arc<RwLock<...>>`.
311///
312/// # Example
313/// ```
314/// use std::sync::Arc;
315/// use parking_lot::RwLock;
316/// use tessera_ui::Dp;
317/// use tessera_ui_basic_components::text_edit_core::{TextEditorState, text_edit_core};
318///
319/// let state = Arc::new(RwLock::new(TextEditorState::new(Dp(16.0), None)));
320/// text_edit_core(state.clone());
321/// ```
322pub fn text_edit_core(state: Arc<RwLock<TextEditorState>>) {
323    // text rendering with constraints from parent container
324    {
325        let state_clone = state.clone();
326        measure(Box::new(move |input| {
327            // surface provides constraints that should be respected for text layout
328            let max_width_pixels: Option<Px> = match input.parent_constraint.width {
329                DimensionValue::Fixed(w) => Some(w),
330                DimensionValue::Wrap { max, .. } => max,
331                DimensionValue::Fill { max, .. } => max,
332            };
333
334            // For proper scrolling behavior, we need to respect height constraints
335            // When max height is specified, content should be clipped and scrollable
336            let max_height_pixels: Option<Px> = match input.parent_constraint.height {
337                DimensionValue::Fixed(h) => Some(h), // Respect explicit fixed heights
338                DimensionValue::Wrap { max, .. } => max, // Respect max height for wrapping
339                DimensionValue::Fill { max, .. } => max,
340            };
341
342            let text_data = state_clone.write().text_data(TextConstraint {
343                max_width: max_width_pixels.map(|px| px.to_f32()),
344                max_height: max_height_pixels.map(|px| px.to_f32()),
345            });
346
347            // Calculate selection rectangles
348            let mut selection_rects = Vec::new();
349            let selection_bounds = state_clone.read().editor.selection_bounds();
350            if let Some((start, end)) = selection_bounds {
351                state_clone.read().editor.with_buffer(|buffer| {
352                    for run in buffer.layout_runs() {
353                        let line_i = run.line_i;
354                        let _line_y = run.line_y; // Px
355                        let line_top = Px(run.line_top as i32); // Px
356                        let line_height = Px(run.line_height as i32); // Px
357
358                        // Highlight selection
359                        if line_i >= start.line && line_i <= end.line {
360                            let mut range_opt: Option<(Px, Px)> = None;
361                            for glyph in run.glyphs.iter() {
362                                // Guess x offset based on characters
363                                let cluster = &run.text[glyph.start..glyph.end];
364                                let total = cluster.grapheme_indices(true).count();
365                                let mut c_x = Px(glyph.x as i32);
366                                let c_w = Px((glyph.w / total as f32) as i32);
367                                for (i, c) in cluster.grapheme_indices(true) {
368                                    let c_start = glyph.start + i;
369                                    let c_end = glyph.start + i + c.len();
370                                    if (start.line != line_i || c_end > start.index)
371                                        && (end.line != line_i || c_start < end.index)
372                                    {
373                                        range_opt = match range_opt.take() {
374                                            Some((min_val, max_val)) => Some((
375                                                // Renamed to avoid conflict
376                                                min_val.min(c_x),
377                                                max_val.max(c_x + c_w),
378                                            )),
379                                            None => Some((c_x, c_x + c_w)),
380                                        };
381                                    } else if let Some((min_val, max_val)) = range_opt.take() {
382                                        // Renamed
383                                        selection_rects.push(RectDef {
384                                            x: min_val,
385                                            y: line_top,
386                                            width: (max_val - min_val).max(Px(0)),
387                                            height: line_height,
388                                        });
389                                    }
390                                    c_x += c_w;
391                                }
392                            }
393
394                            if run.glyphs.is_empty() && end.line > line_i {
395                                // Highlight all of internal empty lines
396                                range_opt =
397                                    Some((Px(0), buffer.size().0.map_or(Px(0), |w| Px(w as i32))));
398                            }
399
400                            if let Some((mut min_val, mut max_val)) = range_opt.take() {
401                                // Renamed
402                                if end.line > line_i {
403                                    // Draw to end of line
404                                    if run.rtl {
405                                        min_val = Px(0);
406                                    } else {
407                                        max_val = buffer.size().0.map_or(Px(0), |w| Px(w as i32));
408                                    }
409                                }
410                                selection_rects.push(RectDef {
411                                    x: min_val,
412                                    y: line_top,
413                                    width: (max_val - min_val).max(Px(0)),
414                                    height: line_height,
415                                });
416                            }
417                        }
418                    }
419                });
420            }
421
422            // Record length before moving
423            let selection_rects_len = selection_rects.len();
424
425            // Handle selection rectangle positioning
426            for (i, rect_def) in selection_rects.iter().enumerate() {
427                if let Some(rect_node_id) = input.children_ids.get(i).copied() {
428                    let _ = input.measure_child(rect_node_id, input.parent_constraint);
429                    input.place_child(rect_node_id, PxPosition::new(rect_def.x, rect_def.y));
430                }
431            }
432
433            // --- Filter and clip selection rects to visible area ---
434            // Only show highlight rects that are (partially) within the visible area
435            let visible_x0 = Px(0);
436            let visible_y0 = Px(0);
437            let visible_x1 = max_width_pixels.unwrap_or(Px(i32::MAX));
438            let visible_y1 = max_height_pixels.unwrap_or(Px(i32::MAX));
439            selection_rects = selection_rects
440                .into_iter()
441                .filter_map(|mut rect| {
442                    let rect_x1 = rect.x + rect.width;
443                    let rect_y1 = rect.y + rect.height;
444                    // If completely outside visible area, skip
445                    if rect_x1 <= visible_x0
446                        || rect.y >= visible_y1
447                        || rect.x >= visible_x1
448                        || rect_y1 <= visible_y0
449                    {
450                        None
451                    } else {
452                        // Clip to visible area
453                        let new_x = rect.x.max(visible_x0);
454                        let new_y = rect.y.max(visible_y0);
455                        let new_x1 = rect_x1.min(visible_x1);
456                        let new_y1 = rect_y1.min(visible_y1);
457                        rect.x = new_x;
458                        rect.y = new_y;
459                        rect.width = (new_x1 - new_x).max(Px(0));
460                        rect.height = (new_y1 - new_y).max(Px(0));
461                        Some(rect)
462                    }
463                })
464                .collect();
465            // Write filtered rects to state
466            state_clone.write().current_selection_rects = selection_rects;
467
468            // Handle cursor positioning (cursor comes after selection rects)
469            if let Some(cursor_pos_raw) = state_clone.read().editor.cursor_position() {
470                let cursor_pos = PxPosition::new(Px(cursor_pos_raw.0), Px(cursor_pos_raw.1));
471                let cursor_node_index = selection_rects_len;
472                if let Some(cursor_node_id) = input.children_ids.get(cursor_node_index).copied() {
473                    let _ = input.measure_child(cursor_node_id, input.parent_constraint);
474                    input.place_child(cursor_node_id, cursor_pos);
475                }
476            }
477
478            let drawable = TextCommand {
479                data: text_data.clone(),
480            };
481            input.metadata_mut().push_draw_command(drawable);
482
483            // Return constrained size - respect maximum height to prevent overflow
484            let constrained_height = if let Some(max_h) = max_height_pixels {
485                text_data.size[1].min(max_h.abs())
486            } else {
487                text_data.size[1]
488            };
489
490            Ok(ComputedData {
491                width: text_data.size[0].into(),
492                height: constrained_height.into(),
493            })
494        }));
495    }
496
497    // Selection highlighting
498    {
499        let (rect_definitions, color_for_selection) = {
500            let guard = state.read();
501            (guard.current_selection_rects.clone(), guard.selection_color)
502        };
503
504        for def in rect_definitions {
505            selection_highlight_rect(def.width, def.height, color_for_selection);
506        }
507    }
508
509    // Cursor rendering (only when focused)
510    if state.read().focus_handler().is_focused() {
511        cursor::cursor(state.read().line_height(), state.read().bink_timer());
512    }
513}
514
515/// Map keyboard events to text editing actions
516/// Maps a keyboard event to a list of text editing actions for the editor.
517///
518/// This function translates keyboard input (including modifiers) into editing actions
519/// such as character insertion, deletion, navigation, and clipboard operations.
520///
521/// # Arguments
522///
523/// * `key_event` - The keyboard event to map.
524/// * `key_modifiers` - The current keyboard modifier state.
525/// * `editor` - Reference to the editor for clipboard operations.
526///
527/// # Returns
528///
529/// An optional vector of `glyphon::Action` to be applied to the editor.
530pub fn map_key_event_to_action(
531    key_event: winit::event::KeyEvent,
532    key_modifiers: winit::keyboard::ModifiersState,
533    editor: &glyphon::Editor,
534    clipboard: &mut Clipboard,
535) -> Option<Vec<glyphon::Action>> {
536    match key_event.state {
537        winit::event::ElementState::Pressed => {}
538        winit::event::ElementState::Released => return None,
539    }
540
541    match key_event.logical_key {
542        winit::keyboard::Key::Named(named_key) => {
543            use glyphon::cosmic_text;
544            use winit::keyboard::NamedKey;
545
546            match named_key {
547                NamedKey::Backspace => Some(vec![glyphon::Action::Backspace]),
548                NamedKey::Delete => Some(vec![glyphon::Action::Delete]),
549                NamedKey::Enter => Some(vec![glyphon::Action::Enter]),
550                NamedKey::Escape => Some(vec![glyphon::Action::Escape]),
551                NamedKey::Tab => Some(vec![glyphon::Action::Insert(' '); 4]),
552                NamedKey::ArrowLeft => {
553                    Some(vec![glyphon::Action::Motion(cosmic_text::Motion::Left)])
554                }
555                NamedKey::ArrowRight => {
556                    Some(vec![glyphon::Action::Motion(cosmic_text::Motion::Right)])
557                }
558                NamedKey::ArrowUp => Some(vec![glyphon::Action::Motion(cosmic_text::Motion::Up)]),
559                NamedKey::ArrowDown => {
560                    Some(vec![glyphon::Action::Motion(cosmic_text::Motion::Down)])
561                }
562                NamedKey::Home => Some(vec![glyphon::Action::Motion(cosmic_text::Motion::Home)]),
563                NamedKey::End => Some(vec![glyphon::Action::Motion(cosmic_text::Motion::End)]),
564                NamedKey::Space => Some(vec![glyphon::Action::Insert(' ')]),
565                _ => None,
566            }
567        }
568        winit::keyboard::Key::Character(s) => {
569            let is_ctrl = key_modifiers.control_key() || key_modifiers.super_key();
570            if is_ctrl {
571                match s.to_lowercase().as_str() {
572                    "c" => {
573                        if let Some(text) = editor.copy_selection() {
574                            clipboard.set_text(&text);
575                        }
576                        return None;
577                    }
578                    "v" => {
579                        if let Some(text) = clipboard.get_text() {
580                            return Some(text.chars().map(glyphon::Action::Insert).collect());
581                        }
582
583                        return None;
584                    }
585                    "x" => {
586                        if let Some(text) = editor.copy_selection() {
587                            clipboard.set_text(&text);
588                            // Use Backspace action to delete selection
589                            return Some(vec![glyphon::Action::Backspace]);
590                        }
591                        return None;
592                    }
593                    _ => {}
594                }
595            }
596            Some(s.chars().map(glyphon::Action::Insert).collect::<Vec<_>>())
597        }
598        _ => None,
599    }
600}