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/// Computes the horizontal scroll (pan) offset that keeps the cursor visible
47/// inside the viewport of a single-line text field.
48///
49/// Mirrors Jetpack Compose's `TextFieldScrollerPosition.coerceOffset` behavior:
50/// - the offset only changes when the cursor would leave the viewport,
51/// - the offset is clamped so the text never detaches from the left edge and
52///   never scrolls further than needed to show the end of the text (plus the
53///   cursor width, so a cursor at the end of the text stays visible).
54///
55/// All values are in px within the field's content coordinate space.
56pub(crate) fn compute_horizontal_scroll_offset(
57    current_offset: f32,
58    cursor_x: f32,
59    text_width: f32,
60    viewport_width: f32,
61) -> f32 {
62    if viewport_width <= 0.0 {
63        return 0.0;
64    }
65    let max_offset = (text_width + CURSOR_WIDTH - viewport_width).max(0.0);
66    let mut offset = current_offset.clamp(0.0, max_offset);
67    let visible_end = offset + viewport_width - CURSOR_WIDTH;
68    if cursor_x > visible_end {
69        // Cursor ran past the right edge: pan so it sits at the right edge.
70        offset = cursor_x - viewport_width + CURSOR_WIDTH;
71    } else if cursor_x < offset {
72        // Cursor ran past the left edge: pan so it sits at the left edge.
73        offset = cursor_x;
74    }
75    offset.clamp(0.0, max_offset)
76}
77
78/// Intersects `rect` with `bounds`, returning `None` when nothing remains.
79///
80/// Used to clip selection/cursor/composition primitives to the field's
81/// viewport so they never draw outside the field bounds.
82pub(crate) fn intersect_rect(
83    rect: cranpose_ui_graphics::Rect,
84    bounds: cranpose_ui_graphics::Rect,
85) -> Option<cranpose_ui_graphics::Rect> {
86    let x0 = rect.x.max(bounds.x);
87    let y0 = rect.y.max(bounds.y);
88    let x1 = (rect.x + rect.width).min(bounds.x + bounds.width);
89    let y1 = (rect.y + rect.height).min(bounds.y + bounds.height);
90    (x1 > x0 && y1 > y0).then_some(cranpose_ui_graphics::Rect {
91        x: x0,
92        y: y0,
93        width: x1 - x0,
94        height: y1 - y0,
95    })
96}
97
98/// Resolver that recomputes (and stores) the horizontal pan offset for a
99/// text field given the current content viewport width in px.
100pub type TextPanResolver = Rc<dyn Fn(f32) -> f32>;
101
102/// Shared references for text field input handling.
103///
104/// This struct bundles the shared state references passed to the pointer input handler,
105/// reducing the argument count for `create_handler` from 8 individual `Rc` parameters
106/// to a single struct (fixing clippy::too_many_arguments).
107#[derive(Clone)]
108pub(crate) struct TextFieldRefs {
109    /// Whether this field is currently focused
110    pub is_focused: Rc<RefCell<bool>>,
111    /// Content offset from left (padding) for accurate click positioning
112    pub content_offset: Rc<Cell<f32>>,
113    /// Content offset from top (padding) for cursor Y positioning
114    pub content_y_offset: Rc<Cell<f32>>,
115    /// Drag anchor position (byte offset) for click-drag selection
116    pub drag_anchor: Rc<Cell<Option<usize>>>,
117    /// Last click time for double/triple-click detection
118    pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
119    /// Click count (1=single, 2=double, 3=triple)
120    pub click_count: Rc<Cell<u8>>,
121    /// Node ID for scoped layout invalidation
122    pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
123    /// Horizontal scroll (pan) offset in px for single-line fields.
124    /// Keeps the cursor visible when the text is wider than the field.
125    pub scroll_offset: Rc<Cell<f32>>,
126}
127
128impl TextFieldRefs {
129    /// Creates a new set of shared references.
130    pub fn new() -> Self {
131        Self {
132            is_focused: Rc::new(RefCell::new(false)),
133            content_offset: Rc::new(Cell::new(0.0_f32)),
134            content_y_offset: Rc::new(Cell::new(0.0_f32)),
135            drag_anchor: Rc::new(Cell::new(None::<usize>)),
136            last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
137            click_count: Rc::new(Cell::new(0_u8)),
138            node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
139            scroll_offset: Rc::new(Cell::new(0.0_f32)),
140        }
141    }
142}
143
144/// Modifier node for editable text fields.
145///
146/// This node is the core of `BasicTextField`, handling:
147/// - Text measurement and layout
148/// - Cursor and selection rendering
149/// - Pointer input for cursor positioning
150use crate::text::TextStyle; // Add import
151
152pub struct TextFieldModifierNode {
153    /// The text field state (shared)
154    state: TextFieldState,
155    /// Shared references for input handling
156    refs: TextFieldRefs,
157    /// Text style
158    style: TextStyle, // Add style
159    /// Cursor brush color
160    cursor_brush: Brush,
161    /// Selection highlight brush
162    selection_brush: Brush,
163    /// Line limits configuration
164    line_limits: TextFieldLineLimits,
165    /// Cached text value for change detection
166    cached_text: String,
167    /// Cached selection for change detection
168    cached_selection: TextRange,
169    /// Node state for delegation
170    node_state: NodeState,
171    /// Measured size cache (shared with the draw closure as the pan viewport)
172    measured_size: Rc<Cell<Size>>,
173    /// Cached line height from last measurement (shared with draw closure)
174    measured_line_height: Rc<Cell<f32>>,
175    /// Cached pointer input handler
176    cached_handler: Rc<dyn Fn(PointerEvent)>,
177    /// Cached horizontal pan resolver (recomputes + stores the scroll offset)
178    cached_pan_resolver: TextPanResolver,
179}
180
181impl std::fmt::Debug for TextFieldModifierNode {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.debug_struct("TextFieldModifierNode")
184            .field("text", &self.state.text())
185            .field("style", &self.style)
186            .field("is_focused", &*self.refs.is_focused.borrow())
187            .finish()
188    }
189}
190
191// Re-export from extracted module
192use crate::text_field_handler::TextFieldHandler;
193
194impl TextFieldModifierNode {
195    /// Creates a new text field modifier node.
196    pub fn new(state: TextFieldState, style: TextStyle) -> Self {
197        let value = state.value();
198        let refs = TextFieldRefs::new();
199        let line_limits = TextFieldLineLimits::default();
200        let cached_handler =
201            Self::create_handler(state.clone(), refs.clone(), line_limits, style.clone());
202        let cached_pan_resolver =
203            Self::create_pan_resolver(state.clone(), refs.clone(), line_limits, style.clone());
204
205        Self {
206            state,
207            refs,
208            style,
209            cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
210            selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
211            line_limits,
212            cached_text: value.text,
213            cached_selection: value.selection,
214            node_state: NodeState::new(),
215            measured_size: Rc::new(Cell::new(Size {
216                width: 0.0,
217                height: 0.0,
218            })),
219            measured_line_height: Rc::new(Cell::new(DEFAULT_LINE_HEIGHT)),
220            cached_handler,
221            cached_pan_resolver,
222        }
223    }
224
225    /// Creates a node with custom line limits.
226    pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
227        self.line_limits = line_limits;
228        self.cached_pan_resolver = Self::create_pan_resolver(
229            self.state.clone(),
230            self.refs.clone(),
231            line_limits,
232            self.style.clone(),
233        );
234        self
235    }
236
237    /// Creates the horizontal pan resolver closure.
238    ///
239    /// The resolver takes the content viewport width (px) and returns the
240    /// horizontal scroll offset that keeps the cursor visible, storing the
241    /// result in `refs.scroll_offset` so pointer input and rendering agree.
242    /// It recomputes from the live state so layout, the render scene builder,
243    /// and the draw closure all observe the same value within a frame.
244    fn create_pan_resolver(
245        state: TextFieldState,
246        refs: TextFieldRefs,
247        line_limits: TextFieldLineLimits,
248        style: TextStyle,
249    ) -> TextPanResolver {
250        Rc::new(move |viewport_width: f32| {
251            if !line_limits.is_single_line() {
252                // Multi-line fields do not pan horizontally.
253                refs.scroll_offset.set(0.0);
254                return 0.0;
255            }
256            let text = state.text();
257            let pos = state.selection().start.min(text.len());
258            let text_width = crate::text::measure_text(
259                &crate::text::AnnotatedString::from(text.as_str()),
260                &style,
261            )
262            .width;
263            let cursor_x = crate::text::measure_text(
264                &crate::text::AnnotatedString::from(&text[..pos]),
265                &style,
266            )
267            .width;
268            let offset = compute_horizontal_scroll_offset(
269                refs.scroll_offset.get(),
270                cursor_x,
271                text_width,
272                viewport_width,
273            );
274            refs.scroll_offset.set(offset);
275            offset
276        })
277    }
278
279    /// Returns the pan resolver for single-line fields, `None` for multi-line.
280    ///
281    /// Exposed to the modifier slices so the render scene builder can pan the
282    /// text glyphs by the same offset used for the cursor and selection.
283    pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
284        self.line_limits
285            .is_single_line()
286            .then(|| self.cached_pan_resolver.clone())
287    }
288
289    /// Returns the current horizontal scroll (pan) offset in px.
290    pub fn scroll_offset(&self) -> f32 {
291        self.refs.scroll_offset.get()
292    }
293
294    /// Returns the current line limits configuration.
295    pub fn line_limits(&self) -> TextFieldLineLimits {
296        self.line_limits
297    }
298
299    /// Creates the pointer input handler closure.
300    fn create_handler(
301        state: TextFieldState,
302        refs: TextFieldRefs,
303        line_limits: TextFieldLineLimits,
304        style: TextStyle, // Add style
305    ) -> Rc<dyn Fn(PointerEvent)> {
306        // Use word_boundaries module for double-click word selection
307        use crate::word_boundaries::find_word_boundaries;
308
309        Rc::new(move |event: PointerEvent| {
310            // Account for content padding offsets and the horizontal pan
311            // offset (single-line fields pan to keep the cursor visible, so
312            // clicks must map back into text space).
313            let click_x =
314                (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
315            let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
316
317            match event.kind {
318                PointerEventKind::Down => {
319                    // Request focus with O(1) handler, passing node_id and line_limits for key handling
320                    let handler =
321                        TextFieldHandler::new(state.clone(), refs.node_id.get(), line_limits);
322                    crate::text_field_focus::request_focus(refs.is_focused.clone(), handler);
323
324                    let now = web_time::Instant::now();
325                    let text = state.text();
326                    let pos = crate::text::get_offset_for_position(
327                        &crate::text::AnnotatedString::from(text.as_str()),
328                        &style,
329                        click_x,
330                        click_y,
331                    );
332
333                    // Detect double-click
334                    let is_double_click = if let Some(last) = refs.last_click_time.get() {
335                        now.duration_since(last).as_millis() < DOUBLE_CLICK_MS
336                    } else {
337                        false
338                    };
339
340                    if is_double_click {
341                        // Increment click count for potential triple-click
342                        let count = refs.click_count.get() + 1;
343                        refs.click_count.set(count.min(3));
344
345                        if count >= 3 {
346                            // Triple-click: select all
347                            state.edit(|buffer| {
348                                buffer.select_all();
349                            });
350                            // Set drag anchor to start for select-all drag
351                            refs.drag_anchor.set(Some(0));
352                        } else if count >= 2 {
353                            // Double-click: select word
354                            let (word_start, word_end) = find_word_boundaries(&text, pos);
355                            state.edit(|buffer| {
356                                buffer.select(TextRange::new(word_start, word_end));
357                            });
358                            // Set drag anchor to word boundaries for word-extend drag
359                            refs.drag_anchor.set(Some(word_start));
360                        }
361                    } else {
362                        // Single click: reset click count, place cursor
363                        refs.click_count.set(1);
364                        refs.drag_anchor.set(Some(pos));
365                        state.edit(|buffer| {
366                            buffer.place_cursor_before_char(pos);
367                        });
368                    }
369
370                    refs.last_click_time.set(Some(now));
371                    event.consume();
372                }
373                PointerEventKind::Move => {
374                    // If we have a drag anchor, extend selection during drag
375                    if let Some(anchor) = refs.drag_anchor.get() {
376                        if *refs.is_focused.borrow() {
377                            let text = state.text();
378                            let current_pos = crate::text::get_offset_for_position(
379                                &crate::text::AnnotatedString::from(text.as_str()),
380                                &style,
381                                click_x,
382                                click_y,
383                            );
384
385                            // Update selection directly (without undo stack push)
386                            state.set_selection(TextRange::new(anchor, current_pos));
387
388                            // Selection change only needs redraw, not layout
389                            crate::request_render_invalidation();
390
391                            event.consume();
392                        }
393                    }
394                }
395                PointerEventKind::Up => {
396                    // Clear drag anchor on mouse up
397                    refs.drag_anchor.set(None);
398                }
399                _ => {}
400            }
401        })
402    }
403
404    /// Creates a node with custom cursor color.
405    pub fn with_cursor_color(mut self, color: Color) -> Self {
406        self.cursor_brush = Brush::solid(color);
407        self
408    }
409
410    /// Sets the focus state.
411    pub fn set_focused(&mut self, focused: bool) {
412        let current = *self.refs.is_focused.borrow();
413        if current != focused {
414            *self.refs.is_focused.borrow_mut() = focused;
415        }
416    }
417
418    /// Returns whether the field is focused.
419    pub fn is_focused(&self) -> bool {
420        *self.refs.is_focused.borrow()
421    }
422
423    /// Returns the is_focused Rc for closure capture.
424    pub fn is_focused_rc(&self) -> Rc<RefCell<bool>> {
425        self.refs.is_focused.clone()
426    }
427
428    /// Returns the content_offset Rc for closure capture.
429    pub fn content_offset_rc(&self) -> Rc<Cell<f32>> {
430        self.refs.content_offset.clone()
431    }
432
433    /// Returns the content_y_offset Rc for closure capture.
434    pub fn content_y_offset_rc(&self) -> Rc<Cell<f32>> {
435        self.refs.content_y_offset.clone()
436    }
437
438    /// Returns the current text.
439    pub fn text(&self) -> String {
440        self.state.text()
441    }
442
443    pub fn style(&self) -> &TextStyle {
444        &self.style
445    }
446
447    /// Returns the current selection.
448    pub fn selection(&self) -> TextRange {
449        self.state.selection()
450    }
451
452    /// Returns the cursor brush for rendering.
453    pub fn cursor_brush(&self) -> Brush {
454        self.cursor_brush.clone()
455    }
456
457    /// Returns the selection brush for rendering selection highlight.
458    pub fn selection_brush(&self) -> Brush {
459        self.selection_brush.clone()
460    }
461
462    /// Inserts text at the current cursor position (for paste operations).
463    pub fn insert_text(&mut self, text: &str) {
464        self.state.edit(|buffer| {
465            buffer.insert(text);
466        });
467    }
468
469    /// Copies the selected text and returns it (for web copy operation).
470    /// Returns None if no selection.
471    pub fn copy_selection(&self) -> Option<String> {
472        self.state.copy_selection()
473    }
474
475    /// Cuts the selected text: copies and deletes it.
476    /// Returns the cut text, or None if no selection.
477    pub fn cut_selection(&mut self) -> Option<String> {
478        let text = self.copy_selection();
479        if text.is_some() {
480            self.state.edit(|buffer| {
481                buffer.delete(buffer.selection());
482            });
483        }
484        text
485    }
486
487    /// Returns a clone of the text field state for use in draw closures.
488    /// This allows reading selection at DRAW time rather than LAYOUT time.
489    pub fn get_state(&self) -> cranpose_foundation::text::TextFieldState {
490        self.state.clone()
491    }
492
493    /// Updates the content offset (padding.left) for accurate click-to-position cursor placement.
494    /// Called from slices collection where padding is known.
495    pub fn set_content_offset(&self, offset: f32) {
496        self.refs.content_offset.set(offset);
497    }
498
499    /// Updates the content Y offset (padding.top) for cursor Y positioning.
500    /// Called from slices collection where padding is known.
501    pub fn set_content_y_offset(&self, offset: f32) {
502        self.refs.content_y_offset.set(offset);
503    }
504
505    /// Measures the text content using node-identity-based caching.
506    fn measure_text_content(&self) -> Size {
507        let text = self.state.text();
508        let node_id = self.refs.node_id.get();
509        let metrics = crate::text::measure_text_for_node(
510            node_id,
511            &crate::text::AnnotatedString::from(text.as_str()),
512            &self.style,
513        );
514        self.measured_line_height.set(metrics.line_height);
515        Size {
516            width: metrics.width,
517            height: metrics.height,
518        }
519    }
520
521    /// Updates cached state and returns true if changed.
522    fn update_cached_state(&mut self) -> bool {
523        let value = self.state.value();
524        let text_changed = value.text != self.cached_text;
525        let selection_changed = value.selection != self.cached_selection;
526
527        if text_changed {
528            self.cached_text = value.text;
529        }
530        if selection_changed {
531            self.cached_selection = value.selection;
532        }
533
534        text_changed || selection_changed
535    }
536
537    /// Positions cursor at a given x offset within the text.
538    /// Uses proper text layout hit testing for accurate proportional font support.
539    pub fn position_cursor_at_offset(&self, x_offset: f32) {
540        let text = self.state.text();
541        if text.is_empty() {
542            self.state.edit(|buffer| {
543                buffer.place_cursor_at_start();
544            });
545            return;
546        }
547
548        // Use proper text layout hit testing instead of character-based calculation.
549        // Map the viewport-relative offset into text space by adding the pan offset.
550        let byte_offset = crate::text::get_offset_for_position(
551            &crate::text::AnnotatedString::from(text.as_str()),
552            &self.style,
553            x_offset + self.refs.scroll_offset.get(),
554            0.0,
555        );
556
557        self.state.edit(|buffer| {
558            buffer.place_cursor_before_char(byte_offset);
559        });
560    }
561
562    // NOTE: Key event handling is done via TextFieldHandler::handle_key() which is
563    // registered with the focus system for O(1) dispatch. DO NOT add a handle_key_event()
564    // method here - it would be duplicate code that never gets called.
565}
566
567impl DelegatableNode for TextFieldModifierNode {
568    fn node_state(&self) -> &NodeState {
569        &self.node_state
570    }
571}
572
573impl ModifierNode for TextFieldModifierNode {
574    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
575        // Store node_id for scoped layout invalidation (avoids O(app) global invalidation)
576        self.refs.node_id.set(context.node_id());
577
578        context.invalidate(InvalidationKind::Layout);
579        context.invalidate(InvalidationKind::Draw);
580        context.invalidate(InvalidationKind::Semantics);
581    }
582
583    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
584        Some(self)
585    }
586
587    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
588        Some(self)
589    }
590
591    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
592        Some(self)
593    }
594
595    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
596        Some(self)
597    }
598
599    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
600        Some(self)
601    }
602
603    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
604        Some(self)
605    }
606
607    fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
608        Some(self)
609    }
610
611    fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
612        Some(self)
613    }
614}
615
616impl LayoutModifierNode for TextFieldModifierNode {
617    fn measure(
618        &self,
619        _context: &mut dyn ModifierNodeContext,
620        _measurable: &dyn Measurable,
621        constraints: Constraints,
622    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
623        // Measure the text content
624        let text_size = self.measure_text_content();
625
626        // Add minimum height for empty text (cursor needs space)
627        let min_height = if text_size.height < 1.0 {
628            DEFAULT_LINE_HEIGHT
629        } else {
630            text_size.height
631        };
632
633        // Constrain to provided constraints
634        let width = text_size
635            .width
636            .max(constraints.min_width)
637            .min(constraints.max_width);
638        let height = min_height
639            .max(constraints.min_height)
640            .min(constraints.max_height);
641
642        let size = Size { width, height };
643        self.measured_size.set(size);
644
645        // Refresh the horizontal pan offset so it is up to date for pointer
646        // input and rendering even before the next draw pass runs.
647        let _ = (self.cached_pan_resolver)(size.width);
648
649        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
650    }
651
652    fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
653        self.measure_text_content().width
654    }
655
656    fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
657        self.measure_text_content().width
658    }
659
660    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
661        self.measure_text_content().height.max(DEFAULT_LINE_HEIGHT)
662    }
663
664    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
665        self.measure_text_content().height.max(DEFAULT_LINE_HEIGHT)
666    }
667}
668
669impl DrawModifierNode for TextFieldModifierNode {
670    fn draw(&self, _draw_scope: &mut dyn DrawScope) {
671        // No-op: Cursor and selection are rendered via create_draw_closure() which
672        // creates DrawPrimitive::Rect directly. This enables draw-time evaluation
673        // of focus state and cursor blink timing.
674    }
675
676    fn create_draw_closure(
677        &self,
678    ) -> Option<Rc<dyn Fn(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
679    {
680        use cranpose_ui_graphics::DrawPrimitive;
681
682        // Capture state via Rc clone (cheap) for draw-time evaluation
683        let is_focused = self.refs.is_focused.clone();
684        let state = self.state.clone();
685        let content_offset = self.refs.content_offset.clone();
686        let content_y_offset = self.refs.content_y_offset.clone();
687        let cursor_brush = self.cursor_brush.clone();
688        let selection_brush = self.selection_brush.clone();
689        let style = self.style.clone();
690        let cached_line_height = self.measured_line_height.clone();
691        let measured_size = self.measured_size.clone();
692        let pan_resolver = self.cached_pan_resolver.clone();
693
694        Some(Rc::new(move |size| {
695            // Check focus at DRAW time
696            if !*is_focused.borrow() {
697                return vec![];
698            }
699
700            let mut primitives = Vec::new();
701
702            let text = state.text();
703            let selection = state.selection();
704            let padding_left = content_offset.get();
705            let padding_top = content_y_offset.get();
706            // Reuse line_height from the most recent layout measurement
707            // instead of re-measuring the full text.
708            let line_height = cached_line_height.get();
709
710            // Content viewport (excludes padding). Fall back to the node size
711            // when measurement has not run yet.
712            let measured = measured_size.get();
713            let viewport_width = if measured.width > 0.0 {
714                measured.width
715            } else {
716                (size.width - padding_left).max(0.0)
717            };
718            let viewport_height = if measured.height > 0.0 {
719                measured.height
720            } else {
721                (size.height - padding_top).max(0.0)
722            };
723            // Horizontal pan that keeps the cursor visible (0 for multi-line).
724            let pan = pan_resolver(viewport_width);
725            // Everything the field draws (selection, IME underline, cursor)
726            // is clipped to the content viewport so primitives never extend
727            // outside the field bounds.
728            let clip_bounds = cranpose_ui_graphics::Rect {
729                x: padding_left,
730                y: padding_top,
731                width: viewport_width,
732                height: viewport_height,
733            };
734
735            // Draw selection highlight
736            if !selection.collapsed() {
737                let sel_start = selection.min();
738                let sel_end = selection.max();
739
740                let lines: Vec<&str> = text.split('\n').collect();
741                let mut byte_offset: usize = 0;
742
743                for (line_idx, line) in lines.iter().enumerate() {
744                    let line_start = byte_offset;
745                    let line_end = byte_offset + line.len();
746
747                    if sel_end > line_start && sel_start < line_end {
748                        let sel_start_in_line = sel_start.saturating_sub(line_start);
749                        let sel_end_in_line = (sel_end - line_start).min(line.len());
750
751                        let sel_start_x = crate::text::measure_text(
752                            &crate::text::AnnotatedString::from(&line[..sel_start_in_line]),
753                            &style,
754                        )
755                        .width
756                            + padding_left
757                            - pan;
758                        let sel_end_x = crate::text::measure_text(
759                            &crate::text::AnnotatedString::from(&line[..sel_end_in_line]),
760                            &style,
761                        )
762                        .width
763                            + padding_left
764                            - pan;
765                        let sel_width = sel_end_x - sel_start_x;
766
767                        if sel_width > 0.0 {
768                            let sel_rect = cranpose_ui_graphics::Rect {
769                                x: sel_start_x,
770                                y: padding_top + line_idx as f32 * line_height,
771                                width: sel_width,
772                                height: line_height,
773                            };
774                            if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
775                                primitives.push(DrawPrimitive::Rect {
776                                    rect: clipped,
777                                    brush: selection_brush.clone(),
778                                });
779                            }
780                        }
781                    }
782                    byte_offset = line_end + 1;
783                }
784            }
785
786            // Draw composition (IME preedit) underline
787            // This shows the user which text is being composed by the input method
788            if let Some(comp_range) = state.composition() {
789                let comp_start = comp_range.min();
790                let comp_end = comp_range.max();
791
792                if comp_start < comp_end && comp_end <= text.len() {
793                    let lines: Vec<&str> = text.split('\n').collect();
794                    let mut byte_offset: usize = 0;
795
796                    // Underline color: slightly transparent white/gray
797                    let underline_brush = cranpose_ui_graphics::Brush::solid(
798                        cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
799                    );
800                    let underline_height: f32 = 2.0;
801
802                    for (line_idx, line) in lines.iter().enumerate() {
803                        let line_start = byte_offset;
804                        let line_end = byte_offset + line.len();
805
806                        // Check if composition overlaps this line
807                        if comp_end > line_start && comp_start < line_end {
808                            let comp_start_in_line = comp_start.saturating_sub(line_start);
809                            let comp_end_in_line = (comp_end - line_start).min(line.len());
810
811                            // Clamp to valid UTF-8 boundaries
812                            let comp_start_in_line = if line.is_char_boundary(comp_start_in_line) {
813                                comp_start_in_line
814                            } else {
815                                0
816                            };
817                            let comp_end_in_line = if line.is_char_boundary(comp_end_in_line) {
818                                comp_end_in_line
819                            } else {
820                                line.len()
821                            };
822
823                            let comp_start_x = crate::text::measure_text(
824                                &crate::text::AnnotatedString::from(&line[..comp_start_in_line]),
825                                &style,
826                            )
827                            .width
828                                + padding_left
829                                - pan;
830                            let comp_end_x = crate::text::measure_text(
831                                &crate::text::AnnotatedString::from(&line[..comp_end_in_line]),
832                                &style,
833                            )
834                            .width
835                                + padding_left
836                                - pan;
837                            let comp_width = comp_end_x - comp_start_x;
838
839                            if comp_width > 0.0 {
840                                // Draw underline at the bottom of the text line
841                                let underline_rect = cranpose_ui_graphics::Rect {
842                                    x: comp_start_x,
843                                    y: padding_top + (line_idx as f32 + 1.0) * line_height
844                                        - underline_height,
845                                    width: comp_width,
846                                    height: underline_height,
847                                };
848                                if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
849                                    primitives.push(DrawPrimitive::Rect {
850                                        rect: clipped,
851                                        brush: underline_brush.clone(),
852                                    });
853                                }
854                            }
855                        }
856                        byte_offset = line_end + 1;
857                    }
858                }
859            }
860
861            // Draw cursor - check visibility at DRAW time for blinking
862            if crate::cursor_animation::is_cursor_visible() {
863                let pos = selection.start.min(text.len());
864                let text_before = &text[..pos];
865                let line_index = text_before.matches('\n').count();
866                let line_start = text_before.rfind('\n').map(|i| i + 1).unwrap_or(0);
867                let cursor_x = crate::text::measure_text(
868                    &crate::text::AnnotatedString::from(&text_before[line_start..]),
869                    &style,
870                )
871                .width
872                    + padding_left
873                    - pan;
874                let cursor_y = padding_top + line_index as f32 * line_height;
875
876                let cursor_rect = cranpose_ui_graphics::Rect {
877                    x: cursor_x,
878                    y: cursor_y,
879                    width: CURSOR_WIDTH,
880                    height: line_height,
881                };
882
883                if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
884                    primitives.push(DrawPrimitive::Rect {
885                        rect: clipped,
886                        brush: cursor_brush.clone(),
887                    });
888                }
889            }
890
891            primitives
892        }))
893    }
894}
895
896impl SemanticsNode for TextFieldModifierNode {
897    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
898        let text = self.state.text();
899        config.content_description = Some(text);
900        config.is_editable_text = true;
901        config.text_selection = Some(self.state.selection());
902    }
903}
904
905impl PointerInputNode for TextFieldModifierNode {
906    fn on_pointer_event(
907        &mut self,
908        _context: &mut dyn ModifierNodeContext,
909        _event: &PointerEvent,
910    ) -> bool {
911        // No-op: All pointer handling is done via pointer_input_handler() closure.
912        // This follows Jetpack Compose's delegation pattern where the node simply
913        // forwards to a delegated pointer input handler (see TextFieldDecoratorModifier.kt:741-747).
914        //
915        // The cached_handler closure handles:
916        // - Focus request on Down
917        // - Cursor positioning
918        // - Double-click word selection
919        // - Triple-click select all
920        // - Drag selection
921        false
922    }
923
924    fn hit_test(&self, x: f32, y: f32) -> bool {
925        // Check if point is within measured bounds
926        let size = self.measured_size.get();
927        x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
928    }
929
930    fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
931        // Return cached handler for pointer input dispatch
932        Some(self.cached_handler.clone())
933    }
934}
935
936// ============================================================================
937// TextFieldElement - Creates and updates TextFieldModifierNode
938// ============================================================================
939
940/// Element that creates and updates `TextFieldModifierNode` instances.
941///
942/// This follows the modifier element pattern where the element is responsible for:
943/// - Creating new nodes (via `create`)
944/// - Updating existing nodes when properties change (via `update`)
945/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
946#[derive(Clone)]
947pub struct TextFieldElement {
948    /// The text field state
949    state: TextFieldState,
950    /// Text style
951    style: TextStyle,
952    /// Cursor color
953    cursor_color: Color,
954    /// Line limits configuration
955    line_limits: TextFieldLineLimits,
956}
957
958impl TextFieldElement {
959    /// Creates a new text field element.
960    pub fn new(state: TextFieldState, style: TextStyle) -> Self {
961        Self {
962            state,
963            style,
964            cursor_color: DEFAULT_CURSOR_COLOR,
965            line_limits: TextFieldLineLimits::default(),
966        }
967    }
968
969    /// Creates an element with custom cursor color.
970    pub fn with_cursor_color(mut self, color: Color) -> Self {
971        self.cursor_color = color;
972        self
973    }
974
975    /// Creates an element with custom line limits.
976    pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
977        self.line_limits = line_limits;
978        self
979    }
980}
981
982impl std::fmt::Debug for TextFieldElement {
983    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
984        f.debug_struct("TextFieldElement")
985            .field("text", &self.state.text())
986            .field("style", &self.style)
987            .field("cursor_color", &self.cursor_color)
988            .finish()
989    }
990}
991
992impl Hash for TextFieldElement {
993    fn hash<H: Hasher>(&self, state: &mut H) {
994        // Hash by state Rc pointer identity - matches PartialEq
995        // This ensures equal elements hash equal (correctness requirement)
996        std::ptr::hash(std::rc::Rc::as_ptr(&self.state.inner), state);
997        // Hash cursor color
998        self.cursor_color.0.to_bits().hash(state);
999        self.cursor_color.1.to_bits().hash(state);
1000        self.cursor_color.2.to_bits().hash(state);
1001        self.cursor_color.3.to_bits().hash(state);
1002        self.style.render_hash().hash(state);
1003        self.line_limits.hash(state);
1004    }
1005}
1006
1007impl PartialEq for TextFieldElement {
1008    fn eq(&self, other: &Self) -> bool {
1009        // Compare by state identity (same Rc), cursor color, and line limits
1010        // This ensures node reuse when same state is passed, while detecting
1011        // actual changes that require updates
1012        self.state == other.state
1013            && self.style == other.style
1014            && self.cursor_color == other.cursor_color
1015            && self.line_limits == other.line_limits
1016    }
1017}
1018
1019impl Eq for TextFieldElement {}
1020
1021impl ModifierNodeElement for TextFieldElement {
1022    type Node = TextFieldModifierNode;
1023
1024    fn create(&self) -> Self::Node {
1025        TextFieldModifierNode::new(self.state.clone(), self.style.clone())
1026            .with_cursor_color(self.cursor_color)
1027            .with_line_limits(self.line_limits)
1028    }
1029
1030    fn update(&self, node: &mut Self::Node) {
1031        // Update the state reference
1032        node.state = self.state.clone();
1033        node.style = self.style.clone();
1034        node.cursor_brush = Brush::solid(self.cursor_color);
1035        node.line_limits = self.line_limits;
1036
1037        // Recreate the cached handler with the new state but same refs
1038        node.cached_handler = TextFieldModifierNode::create_handler(
1039            node.state.clone(),
1040            node.refs.clone(),
1041            node.line_limits,
1042            self.style.clone(),
1043        );
1044
1045        // Recreate the pan resolver so it captures the new state/style/limits
1046        node.cached_pan_resolver = TextFieldModifierNode::create_pan_resolver(
1047            node.state.clone(),
1048            node.refs.clone(),
1049            node.line_limits,
1050            self.style.clone(),
1051        );
1052
1053        // Check if content changed and update cache
1054        if node.update_cached_state() {
1055            // Content changed - node will need layout/draw invalidation
1056            // This happens automatically through the modifier reconciliation
1057        }
1058    }
1059
1060    fn capabilities(&self) -> NodeCapabilities {
1061        NodeCapabilities::LAYOUT
1062            | NodeCapabilities::DRAW
1063            | NodeCapabilities::SEMANTICS
1064            | NodeCapabilities::POINTER_INPUT
1065    }
1066
1067    fn always_update(&self) -> bool {
1068        // Always update to capture new state/handler while preserving focus state
1069        true
1070    }
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075    use super::*;
1076    use crate::text::TextStyle;
1077    use cranpose_core::{DefaultScheduler, Runtime};
1078    use std::sync::Arc;
1079
1080    /// Sets up a test runtime and keeps it alive for the duration of the test.
1081    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1082        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1083        f()
1084    }
1085
1086    #[test]
1087    fn text_field_node_creation() {
1088        let _app_context = crate::render_state::app_context_test_scope();
1089        with_test_runtime(|| {
1090            let state = TextFieldState::new("Hello");
1091            let node = TextFieldModifierNode::new(state, TextStyle::default());
1092            assert_eq!(node.text(), "Hello");
1093            assert!(!node.is_focused());
1094        });
1095    }
1096
1097    #[test]
1098    fn text_field_node_focus() {
1099        let _app_context = crate::render_state::app_context_test_scope();
1100        with_test_runtime(|| {
1101            let state = TextFieldState::new("Test");
1102            let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1103            assert!(!node.is_focused());
1104
1105            node.set_focused(true);
1106            assert!(node.is_focused());
1107
1108            node.set_focused(false);
1109            assert!(!node.is_focused());
1110        });
1111    }
1112
1113    #[test]
1114    fn text_field_element_creates_node() {
1115        let _app_context = crate::render_state::app_context_test_scope();
1116        with_test_runtime(|| {
1117            let state = TextFieldState::new("Hello World");
1118            let element = TextFieldElement::new(state, TextStyle::default());
1119
1120            let node = element.create();
1121            assert_eq!(node.text(), "Hello World");
1122        });
1123    }
1124
1125    #[test]
1126    fn text_field_element_equality() {
1127        let _app_context = crate::render_state::app_context_test_scope();
1128        with_test_runtime(|| {
1129            let state1 = TextFieldState::new("Hello");
1130            let state2 = TextFieldState::new("Hello"); // Different Rc, same text
1131
1132            let elem1 = TextFieldElement::new(state1.clone(), TextStyle::default());
1133            let elem2 = TextFieldElement::new(state1.clone(), TextStyle::default()); // Same state (Rc identity)
1134            let elem3 = TextFieldElement::new(state2, TextStyle::default()); // Different state
1135
1136            // Elements are equal only when they share the same state Rc
1137            // This ensures proper Eq/Hash contract compliance
1138            assert_eq!(elem1, elem2, "Same state should be equal");
1139            assert_ne!(elem1, elem3, "Different states should not be equal");
1140        });
1141    }
1142
1143    #[test]
1144    fn text_field_element_update_refreshes_existing_node_style() {
1145        let _app_context = crate::render_state::app_context_test_scope();
1146        with_test_runtime(|| {
1147            let state = TextFieldState::new("themed text");
1148            let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
1149                color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
1150                ..crate::text::SpanStyle::default()
1151            });
1152            let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
1153                color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
1154                ..crate::text::SpanStyle::default()
1155            });
1156            let initial = TextFieldElement::new(state.clone(), dark_style);
1157            let updated = TextFieldElement::new(state, light_style.clone());
1158            let mut node = initial.create();
1159
1160            updated.update(&mut node);
1161
1162            assert_eq!(node.text(), "themed text");
1163            assert_eq!(node.style(), &light_style);
1164        });
1165    }
1166
1167    /// Test that cursor draw command position is calculated correctly.
1168    ///
1169    /// This test verifies that when we measure text width for cursor position:
1170    /// 1. The cursor x position = width of text before cursor
1171    /// 2. For text at cursor end, x = full text width
1172    #[test]
1173    fn test_cursor_x_position_calculation() {
1174        let _app_context = crate::render_state::app_context_test_scope();
1175        with_test_runtime(|| {
1176            // Test that text measurement works correctly for cursor positioning
1177            let style = crate::text::TextStyle::default();
1178
1179            // Empty text - cursor should be at x=0
1180            let empty_width =
1181                crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
1182            assert!(
1183                empty_width.abs() < 0.1,
1184                "Empty text should have 0 width, got {}",
1185                empty_width
1186            );
1187
1188            // Non-empty text - cursor at end should be at text width
1189            let hi_width =
1190                crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
1191            assert!(
1192                hi_width > 0.0,
1193                "Text 'Hi' should have positive width: {}",
1194                hi_width
1195            );
1196
1197            // Partial text - cursor after 'H' should be at width of 'H'
1198            let h_width =
1199                crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
1200            assert!(h_width > 0.0, "Text 'H' should have positive width");
1201            assert!(
1202                h_width < hi_width,
1203                "'H' width {} should be less than 'Hi' width {}",
1204                h_width,
1205                hi_width
1206            );
1207
1208            // Verify TextFieldState selection tracks cursor correctly
1209            let state = TextFieldState::new("Hi");
1210            assert_eq!(
1211                state.selection().start,
1212                2,
1213                "Cursor should be at position 2 (end of 'Hi')"
1214            );
1215
1216            // The text before cursor at position 2 in "Hi" is "Hi" itself
1217            let text = state.text();
1218            let cursor_pos = state.selection().start;
1219            let text_before_cursor = &text[..cursor_pos.min(text.len())];
1220            assert_eq!(text_before_cursor, "Hi");
1221
1222            // So cursor x = width of "Hi"
1223            let cursor_x = crate::text::measure_text(
1224                &crate::text::AnnotatedString::from(text_before_cursor),
1225                &style,
1226            )
1227            .width;
1228            assert!(
1229                (cursor_x - hi_width).abs() < 0.1,
1230                "Cursor x {} should equal 'Hi' width {}",
1231                cursor_x,
1232                hi_width
1233            );
1234        });
1235    }
1236
1237    /// Test cursor is created when focused node is in slices.
1238    #[test]
1239    fn test_focused_node_creates_cursor() {
1240        let _app_context = crate::render_state::app_context_test_scope();
1241        with_test_runtime(|| {
1242            let state = TextFieldState::new("Test");
1243            let element = TextFieldElement::new(state.clone(), TextStyle::default());
1244            let node = element.create();
1245
1246            // Initially not focused
1247            assert!(!node.is_focused());
1248
1249            // Set focus
1250            *node.refs.is_focused.borrow_mut() = true;
1251            assert!(node.is_focused());
1252
1253            // Verify the node has correct text
1254            assert_eq!(node.text(), "Test");
1255
1256            // Verify selection is at end
1257            assert_eq!(node.selection().start, 4);
1258        });
1259    }
1260}