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