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}