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