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