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