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