Skip to main content

cranpose_ui/
text_field_modifier_node.rs

1//! Text field modifier node for editable text input.
2//!
3//! This module implements the modifier node for `BasicTextField`, following
4//! Jetpack Compose's `CoreTextFieldNode` architecture.
5//!
6//! The node handles:
7//! - **Layout**: Measures text content and returns appropriate size
8//! - **Draw**: Renders text, cursor, and selection highlights
9//! - **Pointer Input**: Handles tap to position cursor, drag for selection
10//! - **Semantics**: Provides text content for accessibility
11//!
12//! # Architecture
13//!
14//! Unlike display-only `TextModifierNode`, this node:
15//! - References a `TextFieldState` for mutable text
16//! - Tracks focus state for cursor visibility
17//! - Handles pointer events for cursor positioning
18
19use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
20use cranpose_foundation::{
21    Constraints, DelegatableNode, DrawModifierNode, DrawScope, InvalidationKind,
22    LayoutModifierNode, Measurable, ModifierNode, ModifierNodeContext, ModifierNodeElement,
23    NodeCapabilities, NodeState, PointerEvent, PointerEventKind, PointerInputNode,
24    SemanticsConfiguration, SemanticsNode, Size,
25};
26use cranpose_ui_graphics::{Brush, Color};
27use std::cell::{Cell, RefCell};
28use std::hash::{Hash, Hasher};
29use std::rc::Rc;
30
31/// Default cursor color (white - visible on dark backgrounds)
32const DEFAULT_CURSOR_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0);
33
34/// Default selection highlight color (light blue with transparency)
35const DEFAULT_SELECTION_COLOR: Color = Color(0.0, 0.5, 1.0, 0.3);
36
37/// Double-click timeout in milliseconds
38const DOUBLE_CLICK_MS: u128 = 500;
39
40/// Default line height for empty text fields
41const DEFAULT_LINE_HEIGHT: f32 = 20.0;
42
43/// Cursor width in pixels
44const CURSOR_WIDTH: f32 = 2.0;
45
46/// Shared references for text field input handling.
47///
48/// This struct bundles the shared state references passed to the pointer input handler,
49/// reducing the argument count for `create_handler` from 8 individual `Rc` parameters
50/// to a single struct (fixing clippy::too_many_arguments).
51#[derive(Clone)]
52pub(crate) struct TextFieldRefs {
53    /// Whether this field is currently focused
54    pub is_focused: Rc<RefCell<bool>>,
55    /// Content offset from left (padding) for accurate click positioning
56    pub content_offset: Rc<Cell<f32>>,
57    /// Content offset from top (padding) for cursor Y positioning
58    pub content_y_offset: Rc<Cell<f32>>,
59    /// Drag anchor position (byte offset) for click-drag selection
60    pub drag_anchor: Rc<Cell<Option<usize>>>,
61    /// Last click time for double/triple-click detection
62    pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
63    /// Click count (1=single, 2=double, 3=triple)
64    pub click_count: Rc<Cell<u8>>,
65    /// Node ID for scoped layout invalidation
66    pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
67}
68
69impl TextFieldRefs {
70    /// Creates a new set of shared references.
71    pub fn new() -> Self {
72        Self {
73            is_focused: Rc::new(RefCell::new(false)),
74            content_offset: Rc::new(Cell::new(0.0_f32)),
75            content_y_offset: Rc::new(Cell::new(0.0_f32)),
76            drag_anchor: Rc::new(Cell::new(None::<usize>)),
77            last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
78            click_count: Rc::new(Cell::new(0_u8)),
79            node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
80        }
81    }
82}
83
84/// Modifier node for editable text fields.
85///
86/// This node is the core of `BasicTextField`, handling:
87/// - Text measurement and layout
88/// - Cursor and selection rendering
89/// - Pointer input for cursor positioning
90use crate::text::TextStyle; // Add import
91
92pub struct TextFieldModifierNode {
93    /// The text field state (shared)
94    state: TextFieldState,
95    /// Shared references for input handling
96    refs: TextFieldRefs,
97    /// Text style
98    style: TextStyle, // Add style
99    /// Cursor brush color
100    cursor_brush: Brush,
101    /// Selection highlight brush
102    selection_brush: Brush,
103    /// Line limits configuration
104    line_limits: TextFieldLineLimits,
105    /// Cached text value for change detection
106    cached_text: String,
107    /// Cached selection for change detection
108    cached_selection: TextRange,
109    /// Node state for delegation
110    node_state: NodeState,
111    /// Measured size cache
112    measured_size: Cell<Size>,
113    /// Cached pointer input handler
114    cached_handler: Rc<dyn Fn(PointerEvent)>,
115}
116
117impl std::fmt::Debug for TextFieldModifierNode {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("TextFieldModifierNode")
120            .field("text", &self.state.text())
121            .field("style", &self.style)
122            .field("is_focused", &*self.refs.is_focused.borrow())
123            .finish()
124    }
125}
126
127// Re-export from extracted module
128use crate::text_field_handler::TextFieldHandler;
129
130impl TextFieldModifierNode {
131    /// Creates a new text field modifier node.
132    pub fn new(state: TextFieldState, style: TextStyle) -> Self {
133        let value = state.value();
134        let refs = TextFieldRefs::new();
135        let line_limits = TextFieldLineLimits::default();
136        let cached_handler =
137            Self::create_handler(state.clone(), refs.clone(), line_limits, style.clone());
138
139        Self {
140            state,
141            refs,
142            style,
143            cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
144            selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
145            line_limits,
146            cached_text: value.text,
147            cached_selection: value.selection,
148            node_state: NodeState::new(),
149            measured_size: Cell::new(Size {
150                width: 0.0,
151                height: 0.0,
152            }),
153            cached_handler,
154        }
155    }
156
157    /// Creates a node with custom line limits.
158    pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
159        self.line_limits = line_limits;
160        self
161    }
162
163    /// Returns the current line limits configuration.
164    pub fn line_limits(&self) -> TextFieldLineLimits {
165        self.line_limits
166    }
167
168    /// Creates the pointer input handler closure.
169    fn create_handler(
170        state: TextFieldState,
171        refs: TextFieldRefs,
172        line_limits: TextFieldLineLimits,
173        style: TextStyle, // Add style
174    ) -> Rc<dyn Fn(PointerEvent)> {
175        // Use word_boundaries module for double-click word selection
176        use crate::word_boundaries::find_word_boundaries;
177
178        Rc::new(move |event: PointerEvent| {
179            // Account for content padding offsets
180            let click_x = (event.position.x - refs.content_offset.get()).max(0.0);
181            let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
182
183            match event.kind {
184                PointerEventKind::Down => {
185                    // Request focus with O(1) handler, passing node_id and line_limits for key handling
186                    let handler =
187                        TextFieldHandler::new(state.clone(), refs.node_id.get(), line_limits);
188                    crate::text_field_focus::request_focus(refs.is_focused.clone(), handler);
189
190                    let now = web_time::Instant::now();
191                    let text = state.text();
192                    let pos = crate::text::get_offset_for_position(
193                        &crate::text::AnnotatedString::from(text.as_str()),
194                        &style,
195                        click_x,
196                        click_y,
197                    );
198
199                    // Detect double-click
200                    let is_double_click = if let Some(last) = refs.last_click_time.get() {
201                        now.duration_since(last).as_millis() < DOUBLE_CLICK_MS
202                    } else {
203                        false
204                    };
205
206                    if is_double_click {
207                        // Increment click count for potential triple-click
208                        let count = refs.click_count.get() + 1;
209                        refs.click_count.set(count.min(3));
210
211                        if count >= 3 {
212                            // Triple-click: select all
213                            state.edit(|buffer| {
214                                buffer.select_all();
215                            });
216                            // Set drag anchor to start for select-all drag
217                            refs.drag_anchor.set(Some(0));
218                        } else if count >= 2 {
219                            // Double-click: select word
220                            let (word_start, word_end) = find_word_boundaries(&text, pos);
221                            state.edit(|buffer| {
222                                buffer.select(TextRange::new(word_start, word_end));
223                            });
224                            // Set drag anchor to word boundaries for word-extend drag
225                            refs.drag_anchor.set(Some(word_start));
226                        }
227                    } else {
228                        // Single click: reset click count, place cursor
229                        refs.click_count.set(1);
230                        refs.drag_anchor.set(Some(pos));
231                        state.edit(|buffer| {
232                            buffer.place_cursor_before_char(pos);
233                        });
234                    }
235
236                    refs.last_click_time.set(Some(now));
237                    event.consume();
238                }
239                PointerEventKind::Move => {
240                    // If we have a drag anchor, extend selection during drag
241                    if let Some(anchor) = refs.drag_anchor.get() {
242                        if *refs.is_focused.borrow() {
243                            let text = state.text();
244                            let current_pos = crate::text::get_offset_for_position(
245                                &crate::text::AnnotatedString::from(text.as_str()),
246                                &style,
247                                click_x,
248                                click_y,
249                            );
250
251                            // Update selection directly (without undo stack push)
252                            state.set_selection(TextRange::new(anchor, current_pos));
253
254                            // Selection change only needs redraw, not layout
255                            crate::request_render_invalidation();
256
257                            event.consume();
258                        }
259                    }
260                }
261                PointerEventKind::Up => {
262                    // Clear drag anchor on mouse up
263                    refs.drag_anchor.set(None);
264                }
265                _ => {}
266            }
267        })
268    }
269
270    /// Creates a node with custom cursor color.
271    pub fn with_cursor_color(mut self, color: Color) -> Self {
272        self.cursor_brush = Brush::solid(color);
273        self
274    }
275
276    /// Sets the focus state.
277    pub fn set_focused(&mut self, focused: bool) {
278        let current = *self.refs.is_focused.borrow();
279        if current != focused {
280            *self.refs.is_focused.borrow_mut() = focused;
281        }
282    }
283
284    /// Returns whether the field is focused.
285    pub fn is_focused(&self) -> bool {
286        *self.refs.is_focused.borrow()
287    }
288
289    /// Returns the is_focused Rc for closure capture.
290    pub fn is_focused_rc(&self) -> Rc<RefCell<bool>> {
291        self.refs.is_focused.clone()
292    }
293
294    /// Returns the content_offset Rc for closure capture.
295    pub fn content_offset_rc(&self) -> Rc<Cell<f32>> {
296        self.refs.content_offset.clone()
297    }
298
299    /// Returns the content_y_offset Rc for closure capture.
300    pub fn content_y_offset_rc(&self) -> Rc<Cell<f32>> {
301        self.refs.content_y_offset.clone()
302    }
303
304    /// Returns the current text.
305    pub fn text(&self) -> String {
306        self.state.text()
307    }
308
309    /// Returns the current selection.
310    pub fn selection(&self) -> TextRange {
311        self.state.selection()
312    }
313
314    /// Returns the cursor brush for rendering.
315    pub fn cursor_brush(&self) -> Brush {
316        self.cursor_brush.clone()
317    }
318
319    /// Returns the selection brush for rendering selection highlight.
320    pub fn selection_brush(&self) -> Brush {
321        self.selection_brush.clone()
322    }
323
324    /// Inserts text at the current cursor position (for paste operations).
325    pub fn insert_text(&mut self, text: &str) {
326        self.state.edit(|buffer| {
327            buffer.insert(text);
328        });
329    }
330
331    /// Copies the selected text and returns it (for web copy operation).
332    /// Returns None if no selection.
333    pub fn copy_selection(&self) -> Option<String> {
334        self.state.copy_selection()
335    }
336
337    /// Cuts the selected text: copies and deletes it.
338    /// Returns the cut text, or None if no selection.
339    pub fn cut_selection(&mut self) -> Option<String> {
340        let text = self.copy_selection();
341        if text.is_some() {
342            self.state.edit(|buffer| {
343                buffer.delete(buffer.selection());
344            });
345        }
346        text
347    }
348
349    /// Returns a clone of the text field state for use in draw closures.
350    /// This allows reading selection at DRAW time rather than LAYOUT time.
351    pub fn get_state(&self) -> cranpose_foundation::text::TextFieldState {
352        self.state.clone()
353    }
354
355    /// Updates the content offset (padding.left) for accurate click-to-position cursor placement.
356    /// Called from slices collection where padding is known.
357    pub fn set_content_offset(&self, offset: f32) {
358        self.refs.content_offset.set(offset);
359    }
360
361    /// Updates the content Y offset (padding.top) for cursor Y positioning.
362    /// Called from slices collection where padding is known.
363    pub fn set_content_y_offset(&self, offset: f32) {
364        self.refs.content_y_offset.set(offset);
365    }
366
367    /// Measures the text content.
368    fn measure_text_content(&self) -> Size {
369        let text = self.state.text();
370        let metrics = crate::text::measure_text(
371            &crate::text::AnnotatedString::from(text.as_str()),
372            &self.style,
373        );
374        Size {
375            width: metrics.width,
376            height: metrics.height,
377        }
378    }
379
380    /// Updates cached state and returns true if changed.
381    fn update_cached_state(&mut self) -> bool {
382        let value = self.state.value();
383        let text_changed = value.text != self.cached_text;
384        let selection_changed = value.selection != self.cached_selection;
385
386        if text_changed {
387            self.cached_text = value.text;
388        }
389        if selection_changed {
390            self.cached_selection = value.selection;
391        }
392
393        text_changed || selection_changed
394    }
395
396    /// Positions cursor at a given x offset within the text.
397    /// Uses proper text layout hit testing for accurate proportional font support.
398    pub fn position_cursor_at_offset(&self, x_offset: f32) {
399        let text = self.state.text();
400        if text.is_empty() {
401            self.state.edit(|buffer| {
402                buffer.place_cursor_at_start();
403            });
404            return;
405        }
406
407        // Use proper text layout hit testing instead of character-based calculation
408        let byte_offset = crate::text::get_offset_for_position(
409            &crate::text::AnnotatedString::from(text.as_str()),
410            &self.style,
411            x_offset,
412            0.0,
413        );
414
415        self.state.edit(|buffer| {
416            buffer.place_cursor_before_char(byte_offset);
417        });
418    }
419
420    // NOTE: Key event handling is done via TextFieldHandler::handle_key() which is
421    // registered with the focus system for O(1) dispatch. DO NOT add a handle_key_event()
422    // method here - it would be duplicate code that never gets called.
423}
424
425impl DelegatableNode for TextFieldModifierNode {
426    fn node_state(&self) -> &NodeState {
427        &self.node_state
428    }
429}
430
431impl ModifierNode for TextFieldModifierNode {
432    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
433        // Store node_id for scoped layout invalidation (avoids O(app) global invalidation)
434        self.refs.node_id.set(context.node_id());
435
436        context.invalidate(InvalidationKind::Layout);
437        context.invalidate(InvalidationKind::Draw);
438        context.invalidate(InvalidationKind::Semantics);
439    }
440
441    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
442        Some(self)
443    }
444
445    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
446        Some(self)
447    }
448
449    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
450        Some(self)
451    }
452
453    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
454        Some(self)
455    }
456
457    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
458        Some(self)
459    }
460
461    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
462        Some(self)
463    }
464
465    fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
466        Some(self)
467    }
468
469    fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
470        Some(self)
471    }
472}
473
474impl LayoutModifierNode for TextFieldModifierNode {
475    fn measure(
476        &self,
477        _context: &mut dyn ModifierNodeContext,
478        _measurable: &dyn Measurable,
479        constraints: Constraints,
480    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
481        // Measure the text content
482        let text_size = self.measure_text_content();
483
484        // Add minimum height for empty text (cursor needs space)
485        let min_height = if text_size.height < 1.0 {
486            DEFAULT_LINE_HEIGHT
487        } else {
488            text_size.height
489        };
490
491        // Constrain to provided constraints
492        let width = text_size
493            .width
494            .max(constraints.min_width)
495            .min(constraints.max_width);
496        let height = min_height
497            .max(constraints.min_height)
498            .min(constraints.max_height);
499
500        let size = Size { width, height };
501        self.measured_size.set(size);
502
503        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
504    }
505
506    fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
507        self.measure_text_content().width
508    }
509
510    fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
511        self.measure_text_content().width
512    }
513
514    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
515        self.measure_text_content().height.max(DEFAULT_LINE_HEIGHT)
516    }
517
518    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
519        self.measure_text_content().height.max(DEFAULT_LINE_HEIGHT)
520    }
521}
522
523impl DrawModifierNode for TextFieldModifierNode {
524    fn draw(&self, _draw_scope: &mut dyn DrawScope) {
525        // No-op: Cursor and selection are rendered via create_draw_closure() which
526        // creates DrawPrimitive::Rect directly. This enables draw-time evaluation
527        // of focus state and cursor blink timing.
528    }
529
530    fn create_draw_closure(
531        &self,
532    ) -> Option<Rc<dyn Fn(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
533    {
534        use cranpose_ui_graphics::DrawPrimitive;
535
536        // Capture state via Rc clone (cheap) for draw-time evaluation
537        let is_focused = self.refs.is_focused.clone();
538        let state = self.state.clone();
539        let content_offset = self.refs.content_offset.clone();
540        let content_y_offset = self.refs.content_y_offset.clone();
541        let cursor_brush = self.cursor_brush.clone();
542        let selection_brush = self.selection_brush.clone();
543        let style = self.style.clone(); // Capture style
544
545        Some(Rc::new(move |_size| {
546            // Check focus at DRAW time
547            if !*is_focused.borrow() {
548                return vec![];
549            }
550
551            let mut primitives = Vec::new();
552
553            let text = state.text();
554            let selection = state.selection();
555            let padding_left = content_offset.get();
556            let padding_top = content_y_offset.get();
557            let line_height = crate::text::measure_text(
558                &crate::text::AnnotatedString::from(text.as_str()),
559                &style,
560            )
561            .line_height;
562
563            // Draw selection highlight
564            if !selection.collapsed() {
565                let sel_start = selection.min();
566                let sel_end = selection.max();
567
568                let lines: Vec<&str> = text.split('\n').collect();
569                let mut byte_offset: usize = 0;
570
571                for (line_idx, line) in lines.iter().enumerate() {
572                    let line_start = byte_offset;
573                    let line_end = byte_offset + line.len();
574
575                    if sel_end > line_start && sel_start < line_end {
576                        let sel_start_in_line = sel_start.saturating_sub(line_start);
577                        let sel_end_in_line = (sel_end - line_start).min(line.len());
578
579                        let sel_start_x = crate::text::measure_text(
580                            &crate::text::AnnotatedString::from(&line[..sel_start_in_line]),
581                            &style,
582                        )
583                        .width
584                            + padding_left;
585                        let sel_end_x = crate::text::measure_text(
586                            &crate::text::AnnotatedString::from(&line[..sel_end_in_line]),
587                            &style,
588                        )
589                        .width
590                            + padding_left;
591                        let sel_width = sel_end_x - sel_start_x;
592
593                        if sel_width > 0.0 {
594                            let sel_rect = cranpose_ui_graphics::Rect {
595                                x: sel_start_x,
596                                y: padding_top + line_idx as f32 * line_height,
597                                width: sel_width,
598                                height: line_height,
599                            };
600                            primitives.push(DrawPrimitive::Rect {
601                                rect: sel_rect,
602                                brush: selection_brush.clone(),
603                            });
604                        }
605                    }
606                    byte_offset = line_end + 1;
607                }
608            }
609
610            // Draw composition (IME preedit) underline
611            // This shows the user which text is being composed by the input method
612            if let Some(comp_range) = state.composition() {
613                let comp_start = comp_range.min();
614                let comp_end = comp_range.max();
615
616                if comp_start < comp_end && comp_end <= text.len() {
617                    let lines: Vec<&str> = text.split('\n').collect();
618                    let mut byte_offset: usize = 0;
619
620                    // Underline color: slightly transparent white/gray
621                    let underline_brush = cranpose_ui_graphics::Brush::solid(
622                        cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
623                    );
624                    let underline_height: f32 = 2.0;
625
626                    for (line_idx, line) in lines.iter().enumerate() {
627                        let line_start = byte_offset;
628                        let line_end = byte_offset + line.len();
629
630                        // Check if composition overlaps this line
631                        if comp_end > line_start && comp_start < line_end {
632                            let comp_start_in_line = comp_start.saturating_sub(line_start);
633                            let comp_end_in_line = (comp_end - line_start).min(line.len());
634
635                            // Clamp to valid UTF-8 boundaries
636                            let comp_start_in_line = if line.is_char_boundary(comp_start_in_line) {
637                                comp_start_in_line
638                            } else {
639                                0
640                            };
641                            let comp_end_in_line = if line.is_char_boundary(comp_end_in_line) {
642                                comp_end_in_line
643                            } else {
644                                line.len()
645                            };
646
647                            let comp_start_x = crate::text::measure_text(
648                                &crate::text::AnnotatedString::from(&line[..comp_start_in_line]),
649                                &style,
650                            )
651                            .width
652                                + padding_left;
653                            let comp_end_x = crate::text::measure_text(
654                                &crate::text::AnnotatedString::from(&line[..comp_end_in_line]),
655                                &style,
656                            )
657                            .width
658                                + padding_left;
659                            let comp_width = comp_end_x - comp_start_x;
660
661                            if comp_width > 0.0 {
662                                // Draw underline at the bottom of the text line
663                                let underline_rect = cranpose_ui_graphics::Rect {
664                                    x: comp_start_x,
665                                    y: padding_top + (line_idx as f32 + 1.0) * line_height
666                                        - underline_height,
667                                    width: comp_width,
668                                    height: underline_height,
669                                };
670                                primitives.push(DrawPrimitive::Rect {
671                                    rect: underline_rect,
672                                    brush: underline_brush.clone(),
673                                });
674                            }
675                        }
676                        byte_offset = line_end + 1;
677                    }
678                }
679            }
680
681            // Draw cursor - check visibility at DRAW time for blinking
682            if crate::cursor_animation::is_cursor_visible() {
683                let pos = selection.start.min(text.len());
684                let text_before = &text[..pos];
685                let line_index = text_before.matches('\n').count();
686                let line_start = text_before.rfind('\n').map(|i| i + 1).unwrap_or(0);
687                let cursor_x = crate::text::measure_text(
688                    &crate::text::AnnotatedString::from(&text_before[line_start..]),
689                    &style,
690                )
691                .width
692                    + padding_left;
693                let cursor_y = padding_top + line_index as f32 * line_height;
694
695                let cursor_rect = cranpose_ui_graphics::Rect {
696                    x: cursor_x,
697                    y: cursor_y,
698                    width: CURSOR_WIDTH,
699                    height: line_height,
700                };
701
702                primitives.push(DrawPrimitive::Rect {
703                    rect: cursor_rect,
704                    brush: cursor_brush.clone(),
705                });
706            }
707
708            primitives
709        }))
710    }
711}
712
713impl SemanticsNode for TextFieldModifierNode {
714    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
715        let text = self.state.text();
716        config.content_description = Some(text);
717        // TODO: Add editable text semantics properties
718        // - is_editable = true
719        // - text_selection_range = self.state.selection()
720    }
721}
722
723impl PointerInputNode for TextFieldModifierNode {
724    fn on_pointer_event(
725        &mut self,
726        _context: &mut dyn ModifierNodeContext,
727        _event: &PointerEvent,
728    ) -> bool {
729        // No-op: All pointer handling is done via pointer_input_handler() closure.
730        // This follows Jetpack Compose's delegation pattern where the node simply
731        // forwards to a delegated pointer input handler (see TextFieldDecoratorModifier.kt:741-747).
732        //
733        // The cached_handler closure handles:
734        // - Focus request on Down
735        // - Cursor positioning
736        // - Double-click word selection
737        // - Triple-click select all
738        // - Drag selection
739        false
740    }
741
742    fn hit_test(&self, x: f32, y: f32) -> bool {
743        // Check if point is within measured bounds
744        let size = self.measured_size.get();
745        x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
746    }
747
748    fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
749        // Return cached handler for pointer input dispatch
750        Some(self.cached_handler.clone())
751    }
752}
753
754// ============================================================================
755// TextFieldElement - Creates and updates TextFieldModifierNode
756// ============================================================================
757
758/// Element that creates and updates `TextFieldModifierNode` instances.
759///
760/// This follows the modifier element pattern where the element is responsible for:
761/// - Creating new nodes (via `create`)
762/// - Updating existing nodes when properties change (via `update`)
763/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
764#[derive(Clone)]
765pub struct TextFieldElement {
766    /// The text field state
767    state: TextFieldState,
768    /// Text style
769    style: TextStyle, // Add style
770    /// Cursor color
771    cursor_color: Color,
772    /// Line limits configuration
773    line_limits: TextFieldLineLimits,
774}
775
776impl TextFieldElement {
777    /// Creates a new text field element.
778    pub fn new(state: TextFieldState, style: TextStyle) -> Self {
779        // Update constructor
780        Self {
781            state,
782            style,
783            cursor_color: DEFAULT_CURSOR_COLOR,
784            line_limits: TextFieldLineLimits::default(),
785        }
786    }
787
788    /// Creates an element with custom cursor color.
789    pub fn with_cursor_color(mut self, color: Color) -> Self {
790        self.cursor_color = color;
791        self
792    }
793
794    /// Creates an element with custom line limits.
795    pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
796        self.line_limits = line_limits;
797        self
798    }
799}
800
801impl std::fmt::Debug for TextFieldElement {
802    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
803        f.debug_struct("TextFieldElement")
804            .field("text", &self.state.text())
805            .field("style", &self.style)
806            .field("cursor_color", &self.cursor_color)
807            .finish()
808    }
809}
810
811impl Hash for TextFieldElement {
812    fn hash<H: Hasher>(&self, state: &mut H) {
813        // Hash by state Rc pointer identity - matches PartialEq
814        // This ensures equal elements hash equal (correctness requirement)
815        std::ptr::hash(std::rc::Rc::as_ptr(&self.state.inner), state);
816        // Hash cursor color
817        self.cursor_color.0.to_bits().hash(state);
818        self.cursor_color.1.to_bits().hash(state);
819        self.cursor_color.2.to_bits().hash(state);
820        self.cursor_color.3.to_bits().hash(state);
821        // TODO: Hash style
822    }
823}
824
825impl PartialEq for TextFieldElement {
826    fn eq(&self, other: &Self) -> bool {
827        // Compare by state identity (same Rc), cursor color, and line limits
828        // This ensures node reuse when same state is passed, while detecting
829        // actual changes that require updates
830        self.state == other.state
831            && self.style == other.style
832            && self.cursor_color == other.cursor_color
833            && self.line_limits == other.line_limits
834    }
835}
836
837impl Eq for TextFieldElement {}
838
839impl ModifierNodeElement for TextFieldElement {
840    type Node = TextFieldModifierNode;
841
842    fn create(&self) -> Self::Node {
843        TextFieldModifierNode::new(self.state.clone(), self.style.clone())
844            .with_cursor_color(self.cursor_color)
845            .with_line_limits(self.line_limits)
846    }
847
848    fn update(&self, node: &mut Self::Node) {
849        // Update the state reference
850        node.state = self.state.clone();
851        node.cursor_brush = Brush::solid(self.cursor_color);
852        node.line_limits = self.line_limits;
853
854        // Recreate the cached handler with the new state but same refs
855        node.cached_handler = TextFieldModifierNode::create_handler(
856            node.state.clone(),
857            node.refs.clone(),
858            node.line_limits,
859            node.style.clone(),
860        );
861
862        // Check if content changed and update cache
863        if node.update_cached_state() {
864            // Content changed - node will need layout/draw invalidation
865            // This happens automatically through the modifier reconciliation
866        }
867    }
868
869    fn capabilities(&self) -> NodeCapabilities {
870        NodeCapabilities::LAYOUT
871            | NodeCapabilities::DRAW
872            | NodeCapabilities::SEMANTICS
873            | NodeCapabilities::POINTER_INPUT
874    }
875
876    fn always_update(&self) -> bool {
877        // Always update to capture new state/handler while preserving focus state
878        true
879    }
880}
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885    use crate::text::TextStyle;
886    use cranpose_core::{DefaultScheduler, Runtime};
887    use std::sync::Arc;
888
889    /// Sets up a test runtime and keeps it alive for the duration of the test.
890    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
891        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
892        f()
893    }
894
895    #[test]
896    fn text_field_node_creation() {
897        with_test_runtime(|| {
898            let state = TextFieldState::new("Hello");
899            let node = TextFieldModifierNode::new(state, TextStyle::default());
900            assert_eq!(node.text(), "Hello");
901            assert!(!node.is_focused());
902        });
903    }
904
905    #[test]
906    fn text_field_node_focus() {
907        with_test_runtime(|| {
908            let state = TextFieldState::new("Test");
909            let mut node = TextFieldModifierNode::new(state, TextStyle::default());
910            assert!(!node.is_focused());
911
912            node.set_focused(true);
913            assert!(node.is_focused());
914
915            node.set_focused(false);
916            assert!(!node.is_focused());
917        });
918    }
919
920    #[test]
921    fn text_field_element_creates_node() {
922        with_test_runtime(|| {
923            let state = TextFieldState::new("Hello World");
924            let element = TextFieldElement::new(state, TextStyle::default());
925
926            let node = element.create();
927            assert_eq!(node.text(), "Hello World");
928        });
929    }
930
931    #[test]
932    fn text_field_element_equality() {
933        with_test_runtime(|| {
934            let state1 = TextFieldState::new("Hello");
935            let state2 = TextFieldState::new("Hello"); // Different Rc, same text
936
937            let elem1 = TextFieldElement::new(state1.clone(), TextStyle::default());
938            let elem2 = TextFieldElement::new(state1.clone(), TextStyle::default()); // Same state (Rc identity)
939            let elem3 = TextFieldElement::new(state2, TextStyle::default()); // Different state
940
941            // Elements are equal only when they share the same state Rc
942            // This ensures proper Eq/Hash contract compliance
943            assert_eq!(elem1, elem2, "Same state should be equal");
944            assert_ne!(elem1, elem3, "Different states should not be equal");
945        });
946    }
947
948    /// Test that cursor draw command position is calculated correctly.
949    ///
950    /// This test verifies that when we measure text width for cursor position:
951    /// 1. The cursor x position = width of text before cursor
952    /// 2. For text at cursor end, x = full text width
953    #[test]
954    fn test_cursor_x_position_calculation() {
955        with_test_runtime(|| {
956            // Test that text measurement works correctly for cursor positioning
957            let style = crate::text::TextStyle::default();
958
959            // Empty text - cursor should be at x=0
960            let empty_width =
961                crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
962            assert!(
963                empty_width.abs() < 0.1,
964                "Empty text should have 0 width, got {}",
965                empty_width
966            );
967
968            // Non-empty text - cursor at end should be at text width
969            let hi_width =
970                crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
971            assert!(
972                hi_width > 0.0,
973                "Text 'Hi' should have positive width: {}",
974                hi_width
975            );
976
977            // Partial text - cursor after 'H' should be at width of 'H'
978            let h_width =
979                crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
980            assert!(h_width > 0.0, "Text 'H' should have positive width");
981            assert!(
982                h_width < hi_width,
983                "'H' width {} should be less than 'Hi' width {}",
984                h_width,
985                hi_width
986            );
987
988            // Verify TextFieldState selection tracks cursor correctly
989            let state = TextFieldState::new("Hi");
990            assert_eq!(
991                state.selection().start,
992                2,
993                "Cursor should be at position 2 (end of 'Hi')"
994            );
995
996            // The text before cursor at position 2 in "Hi" is "Hi" itself
997            let text = state.text();
998            let cursor_pos = state.selection().start;
999            let text_before_cursor = &text[..cursor_pos.min(text.len())];
1000            assert_eq!(text_before_cursor, "Hi");
1001
1002            // So cursor x = width of "Hi"
1003            let cursor_x = crate::text::measure_text(
1004                &crate::text::AnnotatedString::from(text_before_cursor),
1005                &style,
1006            )
1007            .width;
1008            assert!(
1009                (cursor_x - hi_width).abs() < 0.1,
1010                "Cursor x {} should equal 'Hi' width {}",
1011                cursor_x,
1012                hi_width
1013            );
1014        });
1015    }
1016
1017    /// Test cursor is created when focused node is in slices.
1018    #[test]
1019    fn test_focused_node_creates_cursor() {
1020        with_test_runtime(|| {
1021            let state = TextFieldState::new("Test");
1022            let element = TextFieldElement::new(state.clone(), TextStyle::default());
1023            let node = element.create();
1024
1025            // Initially not focused
1026            assert!(!node.is_focused());
1027
1028            // Set focus
1029            *node.refs.is_focused.borrow_mut() = true;
1030            assert!(node.is_focused());
1031
1032            // Verify the node has correct text
1033            assert_eq!(node.text(), "Test");
1034
1035            // Verify selection is at end
1036            assert_eq!(node.selection().start, 4);
1037        });
1038    }
1039}