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