tessera_ui_basic_components/
text_editor.rs

1use std::sync::Arc;
2
3use derive_builder::Builder;
4use glyphon::{Action, Edit};
5use parking_lot::RwLock;
6use tessera_ui::{
7    Color, CursorEventContent, DimensionValue, Dp, ImeRequest, Px, PxPosition, winit,
8};
9use tessera_ui_macros::tessera;
10
11use crate::{
12    pipelines::write_font_system,
13    pos_misc::is_position_in_component,
14    shape_def::Shape,
15    surface::{SurfaceArgsBuilder, surface},
16    text_edit_core::{ClickType, map_key_event_to_action, text_edit_core},
17};
18
19pub use crate::text_edit_core::TextEditorState;
20
21/// Arguments for the `text_editor` component.
22///
23/// # Example
24/// ```
25/// use tessera_ui_basic_components::text_editor::{TextEditorArgs, TextEditorArgsBuilder, TextEditorState};
26/// use tessera_ui::{Dp, DimensionValue, Px};
27/// use std::sync::Arc;
28/// use parking_lot::RwLock;
29///
30/// // Create a text editor with a fixed width and height.
31/// let editor_args_fixed = TextEditorArgsBuilder::default()
32///     .width(Some(DimensionValue::Fixed(Px(200)))) // pixels
33///     .height(Some(DimensionValue::Fixed(Px(100)))) // pixels
34///     .build()
35///     .unwrap();
36///
37/// // Create a text editor that fills available width up to 500px, with a min width of 50px
38/// let editor_args_fill_wrap = TextEditorArgsBuilder::default()
39///     .width(Some(DimensionValue::Fill { min: Some(Px(50)), max: Some(Px(500)) })) // pixels
40///     .height(Some(DimensionValue::Wrap { min: None, max: None }))
41///     .build()
42///     .unwrap();
43///
44/// // Create the editor state
45/// let editor_state = Arc::new(RwLock::new(TextEditorState::new(Dp(10.0), None)));
46///
47/// // text_editor(editor_args_fixed, editor_state.clone());
48/// // text_editor(editor_args_fill_wrap, editor_state.clone());
49/// ```
50#[derive(Debug, Default, Builder, Clone)]
51#[builder(pattern = "owned")]
52pub struct TextEditorArgs {
53    /// Optional width constraint for the text editor. Values are in logical pixels.
54    #[builder(default = "None")]
55    pub width: Option<DimensionValue>,
56    /// Optional height constraint for the text editor. Values are in logical pixels.
57    #[builder(default = "None")]
58    pub height: Option<DimensionValue>,
59    /// Minimum width in density-independent pixels. Defaults to 120dp if not specified.
60    #[builder(default = "None")]
61    pub min_width: Option<Dp>,
62    /// Minimum height in density-independent pixels. Defaults to line height + padding if not specified.
63    #[builder(default = "None")]
64    pub min_height: Option<Dp>,
65    /// Background color of the text editor (RGBA). Defaults to light gray.
66    #[builder(default = "None")]
67    pub background_color: Option<Color>,
68    /// Border width in pixels. Defaults to 1.0.
69    #[builder(default = "1.0")]
70    pub border_width: f32,
71    /// Border color (RGBA). Defaults to gray.
72    #[builder(default = "None")]
73    pub border_color: Option<Color>,
74    /// The shape of the text editor.
75    #[builder(default = "Shape::RoundedRectangle { corner_radius: 4.0, g2_k_value: 3.0 }")]
76    pub shape: Shape,
77    /// Padding inside the text editor. Defaults to 5.0.
78    #[builder(default = "Dp(5.0)")]
79    pub padding: Dp,
80    /// Border color when focused (RGBA). Defaults to blue.
81    #[builder(default = "None")]
82    pub focus_border_color: Option<Color>,
83    /// Background color when focused (RGBA). Defaults to white.
84    #[builder(default = "None")]
85    pub focus_background_color: Option<Color>,
86    /// Color for text selection highlight (RGBA). Defaults to light blue with transparency.
87    #[builder(default = "Some(Color::new(0.5, 0.7, 1.0, 0.4))")]
88    pub selection_color: Option<Color>,
89}
90
91/// A text editor component with two-layer architecture:
92/// - surface layer: provides visual container, minimum size, and click area
93/// - Core layer: handles text rendering and editing logic
94///
95/// This design solves the issue where empty text editors had zero width and couldn't be clicked.
96///
97/// # Example
98///
99/// ```
100/// use tessera_ui_basic_components::text_editor::{text_editor, TextEditorArgs, TextEditorArgsBuilder, TextEditorState};
101/// use tessera_ui::{Dp, DimensionValue, Px};
102/// use std::sync::Arc;
103/// use parking_lot::RwLock;
104///
105/// let args = TextEditorArgsBuilder::default()
106///     .width(Some(DimensionValue::Fixed(Px(300))))
107///     .height(Some(DimensionValue::Fill { min: Some(Px(50)), max: Some(Px(500)) }))
108///     .build()
109///     .unwrap();
110///
111/// let state = Arc::new(RwLock::new(TextEditorState::new(Dp(12.0), None)));
112/// // text_editor(args, state);
113/// ```
114#[tessera]
115pub fn text_editor(args: impl Into<TextEditorArgs>, state: Arc<RwLock<TextEditorState>>) {
116    let editor_args: TextEditorArgs = args.into();
117
118    // Update the state with the selection color from args
119    if let Some(selection_color) = editor_args.selection_color {
120        state.write().set_selection_color(selection_color);
121    }
122
123    // surface layer - provides visual container and minimum size guarantee
124    {
125        let state_for_surface = state.clone();
126        let args_for_surface = editor_args.clone();
127        surface(
128            create_surface_args(&args_for_surface, &state_for_surface),
129            None, // text editors are not interactive at surface level
130            move || {
131                // Core layer - handles text rendering and editing logic
132                text_edit_core(state_for_surface.clone());
133            },
134        );
135    }
136
137    // Event handling at the outermost layer - can access full surface area
138    {
139        let state_for_handler = state.clone();
140        state_handler(Box::new(move |input| {
141            let size = input.computed_data; // This is the full surface size
142            let cursor_pos_option = input.cursor_position;
143            let is_cursor_in_editor = cursor_pos_option
144                .map(|pos| is_position_in_component(size, pos))
145                .unwrap_or(false);
146
147            // Set text input cursor when hovering
148            if is_cursor_in_editor {
149                input.requests.cursor_icon = winit::window::CursorIcon::Text;
150            }
151
152            // Handle click events - now we have a full clickable area from surface
153            if is_cursor_in_editor {
154                // Handle mouse pressed events
155                let click_events: Vec<_> = input
156                    .cursor_events
157                    .iter()
158                    .filter(|event| matches!(event.content, CursorEventContent::Pressed(_)))
159                    .collect();
160
161                // Handle mouse released events (end of drag)
162                let release_events: Vec<_> = input
163                    .cursor_events
164                    .iter()
165                    .filter(|event| matches!(event.content, CursorEventContent::Released(_)))
166                    .collect();
167
168                if !click_events.is_empty() {
169                    // Request focus if not already focused
170                    if !state_for_handler.read().focus_handler().is_focused() {
171                        state_for_handler
172                            .write()
173                            .focus_handler_mut()
174                            .request_focus();
175                    }
176
177                    // Handle cursor positioning for clicks
178                    if let Some(cursor_pos) = cursor_pos_option {
179                        // Calculate the relative position within the text area
180                        let padding_px: Px = editor_args.padding.into();
181                        let border_width_px = Px(editor_args.border_width as i32); // Assuming border_width is integer pixels
182
183                        let text_relative_x_px = cursor_pos.x - padding_px - border_width_px;
184                        let text_relative_y_px = cursor_pos.y - padding_px - border_width_px;
185
186                        // Only process if the click is within the text area (non-negative relative coords)
187                        if text_relative_x_px >= Px(0) && text_relative_y_px >= Px(0) {
188                            let text_relative_pos =
189                                PxPosition::new(text_relative_x_px, text_relative_y_px);
190                            // Determine click type and handle accordingly
191                            let click_type = state_for_handler
192                                .write()
193                                .handle_click(text_relative_pos, click_events[0].timestamp);
194
195                            match click_type {
196                                ClickType::Single => {
197                                    // Single click: position cursor
198                                    state_for_handler.write().editor_mut().action(
199                                        &mut write_font_system(),
200                                        Action::Click {
201                                            x: text_relative_pos.x.0,
202                                            y: text_relative_pos.y.0,
203                                        },
204                                    );
205                                }
206                                ClickType::Double => {
207                                    // Double click: select word
208                                    state_for_handler.write().editor_mut().action(
209                                        &mut write_font_system(),
210                                        Action::DoubleClick {
211                                            x: text_relative_pos.x.0,
212                                            y: text_relative_pos.y.0,
213                                        },
214                                    );
215                                }
216                                ClickType::Triple => {
217                                    // Triple click: select line
218                                    state_for_handler.write().editor_mut().action(
219                                        &mut write_font_system(),
220                                        Action::TripleClick {
221                                            x: text_relative_pos.x.0,
222                                            y: text_relative_pos.y.0,
223                                        },
224                                    );
225                                }
226                            }
227
228                            // Start potential drag operation
229                            state_for_handler.write().start_drag();
230                        }
231                    }
232                }
233
234                // Handle drag events (mouse move while dragging)
235                // This happens every frame when cursor position changes during drag
236                if state_for_handler.read().is_dragging()
237                    && let Some(cursor_pos) = cursor_pos_option
238                {
239                    let padding_px: Px = editor_args.padding.into();
240                    let border_width_px = Px(editor_args.border_width as i32);
241
242                    let text_relative_x_px = cursor_pos.x - padding_px - border_width_px;
243                    let text_relative_y_px = cursor_pos.y - padding_px - border_width_px;
244
245                    if text_relative_x_px >= Px(0) && text_relative_y_px >= Px(0) {
246                        let current_pos_px =
247                            PxPosition::new(text_relative_x_px, text_relative_y_px);
248                        let last_pos_px = state_for_handler.read().last_click_position();
249
250                        if last_pos_px != Some(current_pos_px) {
251                            // Extend selection by dragging
252                            state_for_handler.write().editor_mut().action(
253                                &mut write_font_system(),
254                                Action::Drag {
255                                    x: current_pos_px.x.0,
256                                    y: current_pos_px.y.0,
257                                },
258                            );
259
260                            // Update last position to current position
261                            state_for_handler
262                                .write()
263                                .update_last_click_position(current_pos_px);
264                        }
265                    }
266                }
267
268                // Handle mouse release events (end drag)
269                if !release_events.is_empty() {
270                    state_for_handler.write().stop_drag();
271                }
272
273                let scroll_events: Vec<_> = input
274                    .cursor_events
275                    .iter()
276                    .filter_map(|event| match &event.content {
277                        CursorEventContent::Scroll(scroll_event) => Some(scroll_event),
278                        _ => None,
279                    })
280                    .collect();
281
282                // Handle scroll events (only when focused and cursor is in editor)
283                if state_for_handler.read().focus_handler().is_focused() {
284                    for scroll_event in scroll_events {
285                        // Convert scroll delta to lines
286                        let lines_to_scroll = scroll_event.delta_y as i32;
287
288                        if lines_to_scroll != 0 {
289                            // Scroll up for positive delta_y, down for negative
290                            let action = glyphon::Action::Scroll {
291                                lines: -lines_to_scroll,
292                            };
293                            state_for_handler
294                                .write()
295                                .editor_mut()
296                                .action(&mut write_font_system(), action);
297                        }
298                    }
299                }
300
301                // Only block cursor events when focused to prevent propagation
302                if state_for_handler.read().focus_handler().is_focused() {
303                    input.cursor_events.clear();
304                }
305            }
306
307            // Handle keyboard events (only when focused)
308            if state_for_handler.read().focus_handler().is_focused() {
309                // Handle keyboard events
310                {
311                    let is_ctrl =
312                        input.key_modifiers.control_key() || input.key_modifiers.super_key();
313
314                    // Custom handling for Ctrl+A (Select All)
315                    let select_all_event_index =
316                        input.keyboard_events.iter().position(|key_event| {
317                            if let winit::keyboard::Key::Character(s) = &key_event.logical_key {
318                                is_ctrl
319                                    && s.to_lowercase() == "a"
320                                    && key_event.state == winit::event::ElementState::Pressed
321                            } else {
322                                false
323                            }
324                        });
325
326                    if let Some(_index) = select_all_event_index {
327                        let mut state = state_for_handler.write();
328                        let editor = state.editor_mut();
329                        // Set cursor to the beginning of the document
330                        editor.set_cursor(glyphon::Cursor::new(0, 0));
331                        // Set selection to start from the beginning
332                        editor.set_selection(glyphon::cosmic_text::Selection::Normal(
333                            glyphon::Cursor::new(0, 0),
334                        ));
335                        // Move cursor to the end, which extends the selection (use BufferEnd for full document)
336                        editor.action(
337                            &mut write_font_system(),
338                            glyphon::Action::Motion(glyphon::cosmic_text::Motion::BufferEnd),
339                        );
340                    } else {
341                        // Original logic for other keys
342                        let mut all_actions = Vec::new();
343                        {
344                            let state = state_for_handler.read();
345                            for key_event in input.keyboard_events.iter().cloned() {
346                                if let Some(actions) = map_key_event_to_action(
347                                    key_event,
348                                    input.key_modifiers,
349                                    state.editor(),
350                                ) {
351                                    all_actions.extend(actions);
352                                }
353                            }
354                        }
355
356                        if !all_actions.is_empty() {
357                            let mut state = state_for_handler.write();
358                            for action in all_actions {
359                                state.editor_mut().action(&mut write_font_system(), action);
360                            }
361                        }
362                    }
363                    // Block all keyboard events to prevent propagation
364                    input.keyboard_events.clear();
365                }
366
367                // Handle IME events
368                {
369                    let ime_events: Vec<_> = input.ime_events.drain(..).collect();
370
371                    for event in ime_events {
372                        let mut state = state_for_handler.write();
373                        match event {
374                            winit::event::Ime::Commit(text) => {
375                                // Clear preedit string if it exists
376                                if let Some(preedit_text) = state.preedit_string.take() {
377                                    for _ in 0..preedit_text.chars().count() {
378                                        state.editor_mut().action(
379                                            &mut write_font_system(),
380                                            glyphon::Action::Backspace,
381                                        );
382                                    }
383                                }
384                                // Insert the committed text
385                                for c in text.chars() {
386                                    state.editor_mut().action(
387                                        &mut write_font_system(),
388                                        glyphon::Action::Insert(c),
389                                    );
390                                }
391                            }
392                            winit::event::Ime::Preedit(text, _cursor_offset) => {
393                                // Remove the old preedit text if it exists
394                                if let Some(old_preedit) = state.preedit_string.take() {
395                                    for _ in 0..old_preedit.chars().count() {
396                                        state.editor_mut().action(
397                                            &mut write_font_system(),
398                                            glyphon::Action::Backspace,
399                                        );
400                                    }
401                                }
402                                // Insert the new preedit text
403                                for c in text.chars() {
404                                    state.editor_mut().action(
405                                        &mut write_font_system(),
406                                        glyphon::Action::Insert(c),
407                                    );
408                                }
409                                state.preedit_string = Some(text.to_string());
410                            }
411                            _ => {}
412                        }
413                    }
414                }
415
416                // Request IME window
417                input.requests.ime_request = Some(ImeRequest::new(size.into()));
418            }
419        }));
420    }
421}
422
423/// Create surface arguments based on editor configuration and state
424fn create_surface_args(
425    args: &TextEditorArgs,
426    state: &Arc<RwLock<TextEditorState>>,
427) -> crate::surface::SurfaceArgs {
428    let mut builder = SurfaceArgsBuilder::default();
429
430    // Set width if available
431    if let Some(width) = args.width {
432        builder = builder.width(width);
433    } else {
434        // Use default with minimum
435        builder = builder.width(DimensionValue::Wrap {
436            min: args.min_width.map(|dp| dp.into()).or(Some(Px(120))), // Default minimum width 120px
437            max: None,
438        });
439    }
440
441    // Set height if available
442    if let Some(height) = args.height {
443        builder = builder.height(height);
444    } else {
445        // Use line height as basis with some padding
446        let line_height_px = state.read().line_height();
447        let padding_px: Px = args.padding.into();
448        let min_height_px = args
449            .min_height
450            .map(|dp| dp.into())
451            .unwrap_or(line_height_px + padding_px * 2 + Px(10)); // +10 for comfortable spacing
452        builder = builder.height(DimensionValue::Wrap {
453            min: Some(min_height_px),
454            max: None,
455        });
456    }
457
458    builder
459        .color(determine_background_color(args, state))
460        .border_width(determine_border_width(args, state))
461        .border_color(determine_border_color(args, state))
462        .shape(args.shape)
463        .padding(args.padding)
464        .build()
465        .unwrap()
466}
467
468/// Determine background color based on focus state
469fn determine_background_color(
470    args: &TextEditorArgs,
471    state: &Arc<RwLock<TextEditorState>>,
472) -> Color {
473    if state.read().focus_handler().is_focused() {
474        args.focus_background_color
475            .or(args.background_color)
476            .unwrap_or(Color::WHITE) // Default white when focused
477    } else {
478        args.background_color
479            .unwrap_or(Color::new(0.95, 0.95, 0.95, 1.0)) // Default light gray when not focused
480    }
481}
482
483/// Determine border width
484fn determine_border_width(args: &TextEditorArgs, _state: &Arc<RwLock<TextEditorState>>) -> f32 {
485    args.border_width
486}
487
488/// Determine border color based on focus state
489fn determine_border_color(
490    args: &TextEditorArgs,
491    state: &Arc<RwLock<TextEditorState>>,
492) -> Option<Color> {
493    if state.read().focus_handler().is_focused() {
494        args.focus_border_color
495            .or(args.border_color)
496            .or(Some(Color::new(0.0, 0.5, 1.0, 1.0))) // Default blue focus border
497    } else {
498        args.border_color.or(Some(Color::new(0.7, 0.7, 0.7, 1.0))) // Default gray border
499    }
500}
501
502/// Convenience constructors for common use cases
503impl TextEditorArgs {
504    /// Create a simple text editor with default styling
505    pub fn simple() -> Self {
506        TextEditorArgsBuilder::default()
507            .min_width(Some(Dp(120.0)))
508            .background_color(Some(Color::WHITE))
509            .border_width(1.0)
510            .border_color(Some(Color::new(0.7, 0.7, 0.7, 1.0)))
511            .shape(Shape::RoundedRectangle {
512                corner_radius: 4.0,
513                g2_k_value: 3.0,
514            })
515            .build()
516            .unwrap()
517    }
518
519    /// Create a text editor with emphasized border for better visibility
520    pub fn outlined() -> Self {
521        Self::simple()
522            .with_border_width(2.0)
523            .with_focus_border_color(Color::new(0.0, 0.5, 1.0, 1.0))
524    }
525
526    /// Create a text editor with no border (minimal style)
527    pub fn minimal() -> Self {
528        TextEditorArgsBuilder::default()
529            .min_width(Some(Dp(120.0)))
530            .background_color(Some(Color::WHITE))
531            .border_width(0.0)
532            .shape(Shape::RoundedRectangle {
533                corner_radius: 0.0,
534                g2_k_value: 3.0,
535            })
536            .build()
537            .unwrap()
538    }
539}
540
541/// Builder methods for fluent API
542impl TextEditorArgs {
543    pub fn with_width(mut self, width: DimensionValue) -> Self {
544        self.width = Some(width);
545        self
546    }
547
548    pub fn with_height(mut self, height: DimensionValue) -> Self {
549        self.height = Some(height);
550        self
551    }
552
553    pub fn with_min_width(mut self, min_width: Dp) -> Self {
554        self.min_width = Some(min_width);
555        self
556    }
557
558    pub fn with_min_height(mut self, min_height: Dp) -> Self {
559        self.min_height = Some(min_height);
560        self
561    }
562
563    pub fn with_background_color(mut self, color: Color) -> Self {
564        self.background_color = Some(color);
565        self
566    }
567
568    pub fn with_border_width(mut self, width: f32) -> Self {
569        self.border_width = width;
570        self
571    }
572
573    pub fn with_border_color(mut self, color: Color) -> Self {
574        self.border_color = Some(color);
575        self
576    }
577
578    pub fn with_shape(mut self, shape: Shape) -> Self {
579        self.shape = shape;
580        self
581    }
582
583    pub fn with_padding(mut self, padding: Dp) -> Self {
584        self.padding = padding;
585        self
586    }
587
588    pub fn with_focus_border_color(mut self, color: Color) -> Self {
589        self.focus_border_color = Some(color);
590        self
591    }
592
593    pub fn with_focus_background_color(mut self, color: Color) -> Self {
594        self.focus_background_color = Some(color);
595        self
596    }
597
598    pub fn with_selection_color(mut self, color: Color) -> Self {
599        self.selection_color = Some(color);
600        self
601    }
602}