tessera_ui_basic_components/
text_edit_core.rs

1mod cursor;
2
3use std::{sync::Arc, time::Instant};
4
5use arboard::Clipboard;
6use glyphon::Edit;
7use parking_lot::RwLock;
8use tessera_ui::{
9    Color, ComputedData, DimensionValue, Dp, Px, PxPosition, focus_state::Focus, measure_node,
10    place_node, winit,
11};
12use tessera_ui_macros::tessera;
13use unicode_segmentation::UnicodeSegmentation;
14
15use crate::{
16    pipelines::{TextCommand, TextConstraint, TextData, write_font_system},
17    selection_highlight_rect::selection_highlight_rect,
18};
19
20/// Definition of a rectangular selection highlight
21#[derive(Clone, Debug)]
22pub struct RectDef {
23    pub x: Px,
24    pub y: Px,
25    pub width: Px,
26    pub height: Px,
27}
28
29/// Types of mouse clicks
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub enum ClickType {
32    Single,
33    Double,
34    Triple,
35}
36
37/// Core text editing state, shared between components
38pub struct TextEditorState {
39    line_height: Px,
40    pub(crate) editor: glyphon::Editor<'static>,
41    bink_timer: Instant,
42    focus_handler: Focus,
43    pub(crate) selection_color: Color,
44    pub(crate) current_selection_rects: Vec<RectDef>,
45    // Click tracking for double/triple click detection
46    last_click_time: Option<Instant>,
47    last_click_position: Option<PxPosition>,
48    click_count: u32,
49    is_dragging: bool,
50    // For IME
51    pub(crate) preedit_string: Option<String>,
52}
53
54impl TextEditorState {
55    pub fn new(size: Dp, line_height: Option<Dp>) -> Self {
56        Self::with_selection_color(size, line_height, Color::new(0.5, 0.7, 1.0, 0.4))
57    }
58
59    pub fn with_selection_color(size: Dp, line_height: Option<Dp>, selection_color: Color) -> Self {
60        let final_line_height = line_height.unwrap_or(Dp(size.0 * 1.2));
61        let line_height_px: Px = final_line_height.into();
62        let mut buffer = glyphon::Buffer::new(
63            &mut write_font_system(),
64            glyphon::Metrics::new(size.to_pixels_f32(), line_height_px.to_f32()),
65        );
66        buffer.set_wrap(&mut write_font_system(), glyphon::Wrap::Glyph);
67        let editor = glyphon::Editor::new(buffer);
68        Self {
69            line_height: line_height_px,
70            editor,
71            bink_timer: Instant::now(),
72            focus_handler: Focus::new(),
73            selection_color,
74            current_selection_rects: Vec::new(),
75            last_click_time: None,
76            last_click_position: None,
77            click_count: 0,
78            is_dragging: false,
79            preedit_string: None,
80        }
81    }
82
83    pub fn line_height(&self) -> Px {
84        self.line_height
85    }
86
87    pub fn text_data(&mut self, constraint: TextConstraint) -> TextData {
88        self.editor.with_buffer_mut(|buffer| {
89            buffer.set_size(
90                &mut write_font_system(),
91                constraint.max_width,
92                constraint.max_height,
93            );
94            buffer.shape_until_scroll(&mut write_font_system(), false);
95        });
96
97        let text_buffer = match self.editor.buffer_ref() {
98            glyphon::cosmic_text::BufferRef::Owned(buffer) => buffer.clone(),
99            glyphon::cosmic_text::BufferRef::Borrowed(buffer) => (**buffer).to_owned(),
100            glyphon::cosmic_text::BufferRef::Arc(buffer) => (**buffer).clone(),
101        };
102
103        TextData::from_buffer(text_buffer)
104    }
105
106    pub fn focus_handler(&self) -> &Focus {
107        &self.focus_handler
108    }
109
110    pub fn focus_handler_mut(&mut self) -> &mut Focus {
111        &mut self.focus_handler
112    }
113
114    pub fn editor(&self) -> &glyphon::Editor<'static> {
115        &self.editor
116    }
117
118    pub fn editor_mut(&mut self) -> &mut glyphon::Editor<'static> {
119        &mut self.editor
120    }
121
122    pub fn bink_timer(&self) -> Instant {
123        self.bink_timer
124    }
125
126    pub fn update_bink_timer(&mut self) {
127        self.bink_timer = Instant::now();
128    }
129
130    pub fn selection_color(&self) -> Color {
131        self.selection_color
132    }
133
134    pub fn current_selection_rects(&self) -> &Vec<RectDef> {
135        &self.current_selection_rects
136    }
137
138    pub fn set_selection_color(&mut self, color: Color) {
139        self.selection_color = color;
140    }
141
142    /// Handle a click event and determine the click type (single, double, triple)
143    pub fn handle_click(&mut self, position: PxPosition, timestamp: Instant) -> ClickType {
144        const DOUBLE_CLICK_TIME_MS: u128 = 500; // 500ms for double click
145        const CLICK_DISTANCE_THRESHOLD: Px = Px(5); // 5 pixels tolerance for position
146
147        let click_type = if let (Some(last_time), Some(last_pos)) =
148            (self.last_click_time, self.last_click_position)
149        {
150            let time_diff = timestamp.duration_since(last_time).as_millis();
151            let distance = (position.x - last_pos.x).abs() + (position.y - last_pos.y).abs();
152
153            if time_diff <= DOUBLE_CLICK_TIME_MS && distance <= CLICK_DISTANCE_THRESHOLD.abs() {
154                self.click_count += 1;
155                match self.click_count {
156                    2 => ClickType::Double,
157                    3 => {
158                        self.click_count = 0; // Reset after triple click
159                        ClickType::Triple
160                    }
161                    _ => ClickType::Single,
162                }
163            } else {
164                self.click_count = 1;
165                ClickType::Single
166            }
167        } else {
168            self.click_count = 1;
169            ClickType::Single
170        };
171
172        self.last_click_time = Some(timestamp);
173        self.last_click_position = Some(position);
174        self.is_dragging = false;
175
176        click_type
177    }
178
179    /// Start drag operation
180    pub fn start_drag(&mut self) {
181        self.is_dragging = true;
182    }
183
184    /// Check if currently dragging
185    pub fn is_dragging(&self) -> bool {
186        self.is_dragging
187    }
188
189    /// Stop drag operation
190    pub fn stop_drag(&mut self) {
191        self.is_dragging = false;
192    }
193
194    /// Get last click position
195    pub fn last_click_position(&self) -> Option<PxPosition> {
196        self.last_click_position
197    }
198
199    /// Update last click position (for drag tracking)
200    pub fn update_last_click_position(&mut self, position: PxPosition) {
201        self.last_click_position = Some(position);
202    }
203}
204
205/// Core text editing component - handles text rendering and cursor, no events
206///
207/// This component is designed to be used inside a container (like surface) that
208/// provides the proper size constraints and handles user interaction events.
209#[tessera]
210pub fn text_edit_core(state: Arc<RwLock<TextEditorState>>) {
211    // text rendering with constraints from parent container
212    {
213        let state_clone = state.clone();
214        measure(Box::new(move |input| {
215            // surface provides constraints that should be respected for text layout
216            let max_width_pixels: Option<Px> = match input.parent_constraint.width {
217                DimensionValue::Fixed(w) => Some(w),
218                DimensionValue::Wrap { max, .. } => max,
219                DimensionValue::Fill { max, .. } => max,
220            };
221
222            // For proper scrolling behavior, we need to respect height constraints
223            // When max height is specified, content should be clipped and scrollable
224            let max_height_pixels: Option<Px> = match input.parent_constraint.height {
225                DimensionValue::Fixed(h) => Some(h), // Respect explicit fixed heights
226                DimensionValue::Wrap { max, .. } => max, // Respect max height for wrapping
227                DimensionValue::Fill { max, .. } => max,
228            };
229
230            let text_data = state_clone.write().text_data(TextConstraint {
231                max_width: max_width_pixels.map(|px| px.to_f32()),
232                max_height: max_height_pixels.map(|px| px.to_f32()),
233            });
234
235            // Calculate selection rectangles
236            let mut selection_rects = Vec::new();
237            let selection_bounds = state_clone.read().editor.selection_bounds();
238            if let Some((start, end)) = selection_bounds {
239                state_clone.read().editor.with_buffer(|buffer| {
240                    for run in buffer.layout_runs() {
241                        let line_i = run.line_i;
242                        let _line_y = run.line_y; // Px
243                        let line_top = Px(run.line_top as i32); // Px
244                        let line_height = Px(run.line_height as i32); // Px
245
246                        // Highlight selection
247                        if line_i >= start.line && line_i <= end.line {
248                            let mut range_opt: Option<(Px, Px)> = None;
249                            for glyph in run.glyphs.iter() {
250                                // Guess x offset based on characters
251                                let cluster = &run.text[glyph.start..glyph.end];
252                                let total = cluster.grapheme_indices(true).count();
253                                let mut c_x = Px(glyph.x as i32);
254                                let c_w = Px((glyph.w / total as f32) as i32);
255                                for (i, c) in cluster.grapheme_indices(true) {
256                                    let c_start = glyph.start + i;
257                                    let c_end = glyph.start + i + c.len();
258                                    if (start.line != line_i || c_end > start.index)
259                                        && (end.line != line_i || c_start < end.index)
260                                    {
261                                        range_opt = match range_opt.take() {
262                                            Some((min_val, max_val)) => Some((
263                                                // Renamed to avoid conflict
264                                                min_val.min(c_x),
265                                                max_val.max(c_x + c_w),
266                                            )),
267                                            None => Some((c_x, c_x + c_w)),
268                                        };
269                                    } else if let Some((min_val, max_val)) = range_opt.take() {
270                                        // Renamed
271                                        selection_rects.push(RectDef {
272                                            x: min_val,
273                                            y: line_top,
274                                            width: (max_val - min_val).max(Px(0)),
275                                            height: line_height,
276                                        });
277                                    }
278                                    c_x += c_w;
279                                }
280                            }
281
282                            if run.glyphs.is_empty() && end.line > line_i {
283                                // Highlight all of internal empty lines
284                                range_opt =
285                                    Some((Px(0), buffer.size().0.map_or(Px(0), |w| Px(w as i32))));
286                            }
287
288                            if let Some((mut min_val, mut max_val)) = range_opt.take() {
289                                // Renamed
290                                if end.line > line_i {
291                                    // Draw to end of line
292                                    if run.rtl {
293                                        min_val = Px(0);
294                                    } else {
295                                        max_val = buffer.size().0.map_or(Px(0), |w| Px(w as i32));
296                                    }
297                                }
298                                selection_rects.push(RectDef {
299                                    x: min_val,
300                                    y: line_top,
301                                    width: (max_val - min_val).max(Px(0)),
302                                    height: line_height,
303                                });
304                            }
305                        }
306                    }
307                });
308            }
309
310            // Record length before moving
311            let selection_rects_len = selection_rects.len();
312
313            // Handle selection rectangle positioning
314            for (i, rect_def) in selection_rects.iter().enumerate() {
315                if let Some(rect_node_id) = input.children_ids.get(i).copied() {
316                    let _ = measure_node(
317                        rect_node_id,
318                        input.parent_constraint,
319                        input.tree,
320                        input.metadatas,
321                        input.compute_resource_manager.clone(),
322                        input.gpu,
323                    );
324                    place_node(
325                        rect_node_id,
326                        PxPosition::new(rect_def.x, rect_def.y),
327                        input.metadatas,
328                    );
329                }
330            }
331
332            // --- Filter and clip selection rects to visible area ---
333            // Only show highlight rects that are (partially) within the visible area
334            let visible_x0 = Px(0);
335            let visible_y0 = Px(0);
336            let visible_x1 = max_width_pixels.unwrap_or(Px(i32::MAX));
337            let visible_y1 = max_height_pixels.unwrap_or(Px(i32::MAX));
338            selection_rects = selection_rects
339                .into_iter()
340                .filter_map(|mut rect| {
341                    let rect_x1 = rect.x + rect.width;
342                    let rect_y1 = rect.y + rect.height;
343                    // If completely outside visible area, skip
344                    if rect_x1 <= visible_x0
345                        || rect.y >= visible_y1
346                        || rect.x >= visible_x1
347                        || rect_y1 <= visible_y0
348                    {
349                        None
350                    } else {
351                        // Clip to visible area
352                        let new_x = rect.x.max(visible_x0);
353                        let new_y = rect.y.max(visible_y0);
354                        let new_x1 = rect_x1.min(visible_x1);
355                        let new_y1 = rect_y1.min(visible_y1);
356                        rect.x = new_x;
357                        rect.y = new_y;
358                        rect.width = (new_x1 - new_x).max(Px(0));
359                        rect.height = (new_y1 - new_y).max(Px(0));
360                        Some(rect)
361                    }
362                })
363                .collect();
364            // Write filtered rects to state
365            state_clone.write().current_selection_rects = selection_rects;
366
367            // Handle cursor positioning (cursor comes after selection rects)
368            if let Some(cursor_pos_raw) = state_clone.read().editor.cursor_position() {
369                let cursor_pos = PxPosition::new(Px(cursor_pos_raw.0), Px(cursor_pos_raw.1));
370                let cursor_node_index = selection_rects_len;
371                if let Some(cursor_node_id) = input.children_ids.get(cursor_node_index).copied() {
372                    let _ = measure_node(
373                        cursor_node_id,
374                        input.parent_constraint,
375                        input.tree,
376                        input.metadatas,
377                        input.compute_resource_manager.clone(),
378                        input.gpu,
379                    );
380                    place_node(cursor_node_id, cursor_pos, input.metadatas);
381                }
382            }
383
384            let drawable = TextCommand {
385                data: text_data.clone(),
386            };
387            if let Some(mut metadata) = input.metadatas.get_mut(&input.current_node_id) {
388                metadata.push_draw_command(drawable);
389            }
390
391            // Return constrained size - respect maximum height to prevent overflow
392            let constrained_height = if let Some(max_h) = max_height_pixels {
393                text_data.size[1].min(max_h.abs())
394            } else {
395                text_data.size[1]
396            };
397
398            Ok(ComputedData {
399                width: text_data.size[0].into(),
400                height: constrained_height.into(),
401            })
402        }));
403    }
404
405    // Selection highlighting
406    {
407        let (rect_definitions, color_for_selection) = {
408            let guard = state.read();
409            (guard.current_selection_rects.clone(), guard.selection_color)
410        };
411
412        for def in rect_definitions {
413            selection_highlight_rect(def.width, def.height, color_for_selection);
414        }
415    }
416
417    // Cursor rendering (only when focused)
418    if state.read().focus_handler().is_focused() {
419        cursor::cursor(state.read().line_height(), state.read().bink_timer());
420    }
421}
422
423/// Map keyboard events to text editing actions
424pub fn map_key_event_to_action(
425    key_event: winit::event::KeyEvent,
426    key_modifiers: winit::keyboard::ModifiersState,
427    editor: &glyphon::Editor,
428) -> Option<Vec<glyphon::Action>> {
429    match key_event.state {
430        winit::event::ElementState::Pressed => {}
431        winit::event::ElementState::Released => return None,
432    }
433
434    match key_event.logical_key {
435        winit::keyboard::Key::Named(named_key) => {
436            use glyphon::cosmic_text;
437            use winit::keyboard::NamedKey;
438
439            match named_key {
440                NamedKey::Backspace => Some(vec![glyphon::Action::Backspace]),
441                NamedKey::Delete => Some(vec![glyphon::Action::Delete]),
442                NamedKey::Enter => Some(vec![glyphon::Action::Enter]),
443                NamedKey::Escape => Some(vec![glyphon::Action::Escape]),
444                NamedKey::Tab => Some(vec![glyphon::Action::Insert(' '); 4]),
445                NamedKey::ArrowLeft => {
446                    Some(vec![glyphon::Action::Motion(cosmic_text::Motion::Left)])
447                }
448                NamedKey::ArrowRight => {
449                    Some(vec![glyphon::Action::Motion(cosmic_text::Motion::Right)])
450                }
451                NamedKey::ArrowUp => Some(vec![glyphon::Action::Motion(cosmic_text::Motion::Up)]),
452                NamedKey::ArrowDown => {
453                    Some(vec![glyphon::Action::Motion(cosmic_text::Motion::Down)])
454                }
455                NamedKey::Home => Some(vec![glyphon::Action::Motion(cosmic_text::Motion::Home)]),
456                NamedKey::End => Some(vec![glyphon::Action::Motion(cosmic_text::Motion::End)]),
457                NamedKey::Space => Some(vec![glyphon::Action::Insert(' ')]),
458                _ => None,
459            }
460        }
461        winit::keyboard::Key::Character(s) => {
462            let is_ctrl = key_modifiers.control_key() || key_modifiers.super_key();
463            if is_ctrl {
464                match s.to_lowercase().as_str() {
465                    "c" => {
466                        if let Some(text) = editor.copy_selection() {
467                            if let Ok(mut clipboard) = Clipboard::new() {
468                                let _ = clipboard.set_text(text);
469                            }
470                        }
471                        return None;
472                    }
473                    "v" => {
474                        if let Ok(mut clipboard) = Clipboard::new() {
475                            if let Ok(text) = clipboard.get_text() {
476                                return Some(text.chars().map(glyphon::Action::Insert).collect());
477                            }
478                        }
479                        return None;
480                    }
481                    "x" => {
482                        if let Some(text) = editor.copy_selection() {
483                            if let Ok(mut clipboard) = Clipboard::new() {
484                                let _ = clipboard.set_text(text);
485                            }
486                            // Use Backspace action to delete selection
487                            return Some(vec![glyphon::Action::Backspace]);
488                        }
489                        return None;
490                    }
491                    _ => {}
492                }
493            }
494            Some(s.chars().map(glyphon::Action::Insert).collect::<Vec<_>>())
495        }
496        _ => None,
497    }
498}