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_core::{mutableStateOf, MutableState};
20use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
21use cranpose_foundation::{
22    Constraints, DelegatableNode, DrawModifierNode, DrawScope, InvalidationKind,
23    LayoutModifierNode, Measurable, ModifierNode, ModifierNodeContext, ModifierNodeElement,
24    NodeCapabilities, NodeState, PointerEvent, PointerEventKind, PointerInputNode, PointerSource,
25    SemanticsConfiguration, SemanticsNode, Size,
26};
27use cranpose_ui_graphics::{Brush, Color, Point};
28use std::cell::{Cell, RefCell};
29use std::hash::{Hash, Hasher};
30use std::rc::Rc;
31
32/// Live geometry a `BasicTextField` needs to place and drive its finger
33/// selection handles: whether the field is focused and was touched, its
34/// on-screen origin (window coordinates) and the metrics that map a window
35/// position back to a text offset.
36#[derive(Clone, Copy, PartialEq, Debug)]
37pub struct TextFieldHandleMetrics {
38    pub focused: bool,
39    pub touch: bool,
40    /// Field node's top-left in window coordinates.
41    pub node_origin: Point,
42    pub padding_left: f32,
43    pub padding_top: f32,
44    pub scroll_offset: f32,
45    pub line_height: f32,
46    /// Width the field wrapped its text at (`None` for single-line fields).
47    /// Lets the handles resolve the same visual (wrapped) lines the caret does.
48    pub wrap_width: Option<f32>,
49}
50
51/// Shared channel by which a `TextFieldModifierNode` publishes its live handle
52/// [`TextFieldHandleMetrics`] to the `BasicTextField` composable that renders
53/// the handles. Reads subscribe reactively (backed by a revision `MutableState`)
54/// so the composable recomposes when the field's focus/geometry changes.
55#[derive(Clone)]
56pub struct TextFieldHandleController {
57    inner: Rc<TextFieldHandleControllerInner>,
58}
59
60impl PartialEq for TextFieldHandleController {
61    fn eq(&self, other: &Self) -> bool {
62        Rc::ptr_eq(&self.inner, &other.inner)
63    }
64}
65
66struct TextFieldHandleControllerInner {
67    metrics: Cell<Option<TextFieldHandleMetrics>>,
68    revision: MutableState<u64>,
69}
70
71impl TextFieldHandleController {
72    /// Creates a controller. Must run with an active runtime (i.e. inside a
73    /// composition, via `remember`).
74    pub fn new() -> Self {
75        Self {
76            inner: Rc::new(TextFieldHandleControllerInner {
77                metrics: Cell::new(None),
78                revision: mutableStateOf(0u64),
79            }),
80        }
81    }
82
83    /// Publishes fresh metrics, waking any reader only when they actually
84    /// changed (so a resting frame does not spin recomposition).
85    pub(crate) fn publish(&self, metrics: TextFieldHandleMetrics) {
86        if self.inner.metrics.get() != Some(metrics) {
87            self.inner.metrics.set(Some(metrics));
88            self.inner
89                .revision
90                .update(|value| *value = value.wrapping_add(1));
91        }
92    }
93
94    /// Reads the latest metrics, subscribing the current recompose scope to
95    /// future changes.
96    pub fn metrics(&self) -> Option<TextFieldHandleMetrics> {
97        let _ = self.inner.revision.value();
98        self.inner.metrics.get()
99    }
100}
101
102impl Default for TextFieldHandleController {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108/// Default cursor color (white - visible on dark backgrounds)
109const DEFAULT_CURSOR_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0);
110
111/// Default selection highlight color (light blue with transparency)
112const DEFAULT_SELECTION_COLOR: Color = Color(0.0, 0.5, 1.0, 0.3);
113
114/// Default line height for empty text fields
115const DEFAULT_LINE_HEIGHT: f32 = 20.0;
116
117/// Cursor width in pixels
118const CURSOR_WIDTH: f32 = 2.0;
119
120/// Computes the horizontal scroll (pan) offset that keeps the cursor visible
121/// inside the viewport of a single-line text field.
122///
123/// Mirrors Jetpack Compose's `TextFieldScrollerPosition.coerceOffset` behavior:
124/// - the offset only changes when the cursor would leave the viewport,
125/// - the offset is clamped so the text never detaches from the left edge and
126///   never scrolls further than needed to show the end of the text (plus the
127///   cursor width, so a cursor at the end of the text stays visible).
128///
129/// All values are in px within the field's content coordinate space.
130pub(crate) fn compute_horizontal_scroll_offset(
131    current_offset: f32,
132    cursor_x: f32,
133    text_width: f32,
134    viewport_width: f32,
135) -> f32 {
136    if viewport_width <= 0.0 {
137        return 0.0;
138    }
139    let max_offset = (text_width + CURSOR_WIDTH - viewport_width).max(0.0);
140    let mut offset = current_offset.clamp(0.0, max_offset);
141    let visible_end = offset + viewport_width - CURSOR_WIDTH;
142    if cursor_x > visible_end {
143        // Cursor ran past the right edge: pan so it sits at the right edge.
144        offset = cursor_x - viewport_width + CURSOR_WIDTH;
145    } else if cursor_x < offset {
146        // Cursor ran past the left edge: pan so it sits at the left edge.
147        offset = cursor_x;
148    }
149    offset.clamp(0.0, max_offset)
150}
151
152/// Intersects `rect` with `bounds`, returning `None` when nothing remains.
153///
154/// Used to clip selection/cursor/composition primitives to the field's
155/// viewport so they never draw outside the field bounds.
156pub(crate) fn intersect_rect(
157    rect: cranpose_ui_graphics::Rect,
158    bounds: cranpose_ui_graphics::Rect,
159) -> Option<cranpose_ui_graphics::Rect> {
160    let x0 = rect.x.max(bounds.x);
161    let y0 = rect.y.max(bounds.y);
162    let x1 = (rect.x + rect.width).min(bounds.x + bounds.width);
163    let y1 = (rect.y + rect.height).min(bounds.y + bounds.height);
164    (x1 > x0 && y1 > y0).then_some(cranpose_ui_graphics::Rect {
165        x: x0,
166        y: y0,
167        width: x1 - x0,
168        height: y1 - y0,
169    })
170}
171
172/// Resolver that recomputes (and stores) the horizontal pan offset for a
173/// text field given the current content viewport width in px.
174pub type TextPanResolver = Rc<dyn Fn(f32) -> f32>;
175
176/// Resolves the caret's visual `(line_index, line_start_byte)` for byte
177/// `offset`.
178///
179/// For a wrapping (multi-line) field this lays the text out at the same wrap
180/// width the field measured and returns the VISUAL line the caret sits on, so
181/// the drawn caret lands on the same glyph the renderer draws. For a
182/// non-wrapping field (single line, or when no wrap width is known yet) it falls
183/// back to counting logical `\n` lines. Shared by the in-content caret and the
184/// overlay selection handles so both agree.
185pub(crate) fn caret_visual_line_for_offset(
186    text: &str,
187    style: &TextStyle,
188    node_id: Option<cranpose_core::NodeId>,
189    wrap_width: Option<f32>,
190    offset: usize,
191) -> (usize, usize) {
192    let offset = offset.min(text.len());
193    match wrap_width {
194        Some(width) if width.is_finite() && width > 0.0 => {
195            let annotated = crate::text::AnnotatedString::from(text);
196            let ranges = crate::text::wrapped_line_ranges(
197                node_id,
198                &annotated,
199                style,
200                crate::text::TextLayoutOptions::default(),
201                Some(width),
202            );
203            crate::text_selection::caret_visual_line(&ranges, offset)
204        }
205        _ => {
206            let before = &text[..offset];
207            let line_index = before.matches('\n').count();
208            let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
209            (line_index, line_start)
210        }
211    }
212}
213
214/// Shared references for text field input handling.
215///
216/// This struct bundles the shared state references passed to the pointer input handler,
217/// reducing the argument count for `create_handler` from 8 individual `Rc` parameters
218/// to a single struct (fixing clippy::too_many_arguments).
219#[derive(Clone)]
220pub(crate) struct TextFieldRefs {
221    /// Whether this field is currently focused
222    pub is_focused: Rc<RefCell<bool>>,
223    /// Content offset from left (padding) for accurate click positioning
224    pub content_offset: Rc<Cell<f32>>,
225    /// Content offset from top (padding) for cursor Y positioning
226    pub content_y_offset: Rc<Cell<f32>>,
227    /// Drag anchor position (byte offset) for click-drag selection
228    pub drag_anchor: Rc<Cell<Option<usize>>>,
229    /// Last click time for double/triple-click detection
230    pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
231    /// Last click screen position, for multi-tap slop gating
232    pub last_click_pos: Rc<Cell<Option<(f32, f32)>>>,
233    /// Click count (1=single, 2=double, 3=triple)
234    pub click_count: Rc<Cell<u8>>,
235    /// Node ID for scoped layout invalidation
236    pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
237    /// Horizontal scroll (pan) offset in px for single-line fields.
238    /// Keeps the cursor visible when the text is wider than the field.
239    pub scroll_offset: Rc<Cell<f32>>,
240    /// Device source of the most recent pointer press on the field. Drives
241    /// touch-only affordances: finger selection handles are shown for touch /
242    /// stylus presses, while a mouse keeps a clean caret.
243    pub last_pointer_source: Rc<Cell<PointerSource>>,
244    /// Field node's top-left in window coordinates, derived from the most recent
245    /// pointer event (`global_position - position`). Used to place selection
246    /// handles in the top-level overlay, which is in window space.
247    pub node_origin: Rc<Cell<Point>>,
248}
249
250impl TextFieldRefs {
251    /// Creates a new set of shared references.
252    pub fn new() -> Self {
253        Self {
254            is_focused: Rc::new(RefCell::new(false)),
255            content_offset: Rc::new(Cell::new(0.0_f32)),
256            content_y_offset: Rc::new(Cell::new(0.0_f32)),
257            drag_anchor: Rc::new(Cell::new(None::<usize>)),
258            last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
259            last_click_pos: Rc::new(Cell::new(None::<(f32, f32)>)),
260            click_count: Rc::new(Cell::new(0_u8)),
261            node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
262            scroll_offset: Rc::new(Cell::new(0.0_f32)),
263            last_pointer_source: Rc::new(Cell::new(PointerSource::Unknown)),
264            node_origin: Rc::new(Cell::new(Point { x: 0.0, y: 0.0 })),
265        }
266    }
267}
268
269/// Modifier node for editable text fields.
270///
271/// This node is the core of `BasicTextField`, handling:
272/// - Text measurement and layout
273/// - Cursor and selection rendering
274/// - Pointer input for cursor positioning
275use crate::text::TextStyle; // Add import
276
277pub struct TextFieldModifierNode {
278    /// The text field state (shared)
279    state: TextFieldState,
280    /// Shared references for input handling
281    refs: TextFieldRefs,
282    /// Text style
283    style: TextStyle, // Add style
284    /// Cursor brush color
285    cursor_brush: Brush,
286    /// Selection highlight brush
287    selection_brush: Brush,
288    /// Line limits configuration
289    line_limits: TextFieldLineLimits,
290    /// Cached text value for change detection
291    cached_text: String,
292    /// Cached selection for change detection
293    cached_selection: TextRange,
294    /// Node state for delegation
295    node_state: NodeState,
296    /// Measured size cache (shared with the draw closure as the pan viewport)
297    measured_size: Rc<Cell<Size>>,
298    /// Cached line height from last measurement (shared with draw closure)
299    measured_line_height: Rc<Cell<f32>>,
300    /// Wrap width the last measurement laid the text out at (`None` for
301    /// single-line fields, which pan instead of wrapping). Shared with the draw
302    /// closure so the caret and selection handles resolve the same *visual*
303    /// (wrapped) lines the renderer draws, instead of counting only logical
304    /// `\n` lines.
305    measured_wrap_width: Rc<Cell<Option<f32>>>,
306    /// Cached pointer input handler
307    cached_handler: Rc<dyn Fn(PointerEvent)>,
308    /// Cached horizontal pan resolver (recomputes + stores the scroll offset)
309    cached_pan_resolver: TextPanResolver,
310    /// Channel to publish live handle metrics to the `BasicTextField`
311    /// composable that renders the finger selection handles. `None` when the
312    /// field is used without handle support.
313    handle_controller: Option<TextFieldHandleController>,
314}
315
316impl std::fmt::Debug for TextFieldModifierNode {
317    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318        f.debug_struct("TextFieldModifierNode")
319            .field("text", &self.state.text())
320            .field("style", &self.style)
321            .field("is_focused", &*self.refs.is_focused.borrow())
322            .finish()
323    }
324}
325
326// Re-export from extracted module
327use crate::text_field_handler::TextFieldHandler;
328
329impl TextFieldModifierNode {
330    /// Creates a new text field modifier node.
331    pub fn new(state: TextFieldState, style: TextStyle) -> Self {
332        let value = state.value();
333        let refs = TextFieldRefs::new();
334        let line_limits = TextFieldLineLimits::default();
335        let cached_handler =
336            Self::create_handler(state.clone(), refs.clone(), line_limits, style.clone());
337        let cached_pan_resolver =
338            Self::create_pan_resolver(state.clone(), refs.clone(), line_limits, style.clone());
339
340        Self {
341            state,
342            refs,
343            style,
344            cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
345            selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
346            line_limits,
347            cached_text: value.text,
348            cached_selection: value.selection,
349            node_state: NodeState::new(),
350            measured_size: Rc::new(Cell::new(Size {
351                width: 0.0,
352                height: 0.0,
353            })),
354            measured_line_height: Rc::new(Cell::new(DEFAULT_LINE_HEIGHT)),
355            measured_wrap_width: Rc::new(Cell::new(None)),
356            cached_handler,
357            cached_pan_resolver,
358            handle_controller: None,
359        }
360    }
361
362    /// Creates a node with custom line limits.
363    pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
364        self.line_limits = line_limits;
365        self.cached_pan_resolver = Self::create_pan_resolver(
366            self.state.clone(),
367            self.refs.clone(),
368            line_limits,
369            self.style.clone(),
370        );
371        self
372    }
373
374    /// Installs the controller the field publishes live handle metrics to.
375    pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
376        self.handle_controller = Some(controller);
377        self
378    }
379
380    /// Creates the horizontal pan resolver closure.
381    ///
382    /// The resolver takes the content viewport width (px) and returns the
383    /// horizontal scroll offset that keeps the cursor visible, storing the
384    /// result in `refs.scroll_offset` so pointer input and rendering agree.
385    /// It recomputes from the live state so layout, the render scene builder,
386    /// and the draw closure all observe the same value within a frame.
387    fn create_pan_resolver(
388        state: TextFieldState,
389        refs: TextFieldRefs,
390        line_limits: TextFieldLineLimits,
391        style: TextStyle,
392    ) -> TextPanResolver {
393        Rc::new(move |viewport_width: f32| {
394            if !line_limits.is_single_line() {
395                // Multi-line fields do not pan horizontally.
396                refs.scroll_offset.set(0.0);
397                return 0.0;
398            }
399            let text = state.text();
400            let pos = state.selection().start.min(text.len());
401            let text_width = crate::text::measure_text(
402                &crate::text::AnnotatedString::from(text.as_str()),
403                &style,
404            )
405            .width;
406            let cursor_x = crate::text::measure_text(
407                &crate::text::AnnotatedString::from(&text[..pos]),
408                &style,
409            )
410            .width;
411            let offset = compute_horizontal_scroll_offset(
412                refs.scroll_offset.get(),
413                cursor_x,
414                text_width,
415                viewport_width,
416            );
417            refs.scroll_offset.set(offset);
418            offset
419        })
420    }
421
422    /// Returns the pan resolver for single-line fields, `None` for multi-line.
423    ///
424    /// Exposed to the modifier slices so the render scene builder can pan the
425    /// text glyphs by the same offset used for the cursor and selection.
426    pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
427        self.line_limits
428            .is_single_line()
429            .then(|| self.cached_pan_resolver.clone())
430    }
431
432    /// Returns the current horizontal scroll (pan) offset in px.
433    pub fn scroll_offset(&self) -> f32 {
434        self.refs.scroll_offset.get()
435    }
436
437    /// Returns the current line limits configuration.
438    pub fn line_limits(&self) -> TextFieldLineLimits {
439        self.line_limits
440    }
441
442    /// Creates the pointer input handler closure.
443    fn create_handler(
444        state: TextFieldState,
445        refs: TextFieldRefs,
446        line_limits: TextFieldLineLimits,
447        style: TextStyle, // Add style
448    ) -> Rc<dyn Fn(PointerEvent)> {
449        // Tap-count classification plus word/line/paragraph boundaries drive the
450        // multi-tap selection granularity gestures.
451        use crate::text_selection::{
452            classify_tap_count, find_line_boundaries, find_paragraph_boundaries,
453            resolve_selection_tap_count, tap_selection_granularity, SelectionGranularity,
454            MULTI_TAP_SLOP_PX, MULTI_TAP_TIMEOUT_MS,
455        };
456        use crate::word_boundaries::find_word_boundaries;
457
458        Rc::new(move |event: PointerEvent| {
459            // Seed the field node's window-space origin from this pointer event
460            // so the very first handle placement after a tap has a value even
461            // before the next layout pass runs. The layout pass
462            // (`window_origin_sink`) is the authoritative source that keeps it
463            // fresh as the field scrolls; both agree (`global - local` equals
464            // the composited window origin at rest).
465            refs.node_origin.set(Point {
466                x: event.global_position.x - event.position.x,
467                y: event.global_position.y - event.position.y,
468            });
469
470            // Account for content padding offsets and the horizontal pan
471            // offset (single-line fields pan to keep the cursor visible, so
472            // clicks must map back into text space).
473            let click_x =
474                (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
475            let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
476
477            match event.kind {
478                PointerEventKind::Down => {
479                    // Remember the device that pressed so the draw closure can
480                    // show finger selection handles for touch/stylus and keep a
481                    // clean caret for a mouse.
482                    refs.last_pointer_source.set(event.source);
483
484                    // Request focus with O(1) handler, passing node_id and line
485                    // limits for key handling plus the live geometry cells the
486                    // layout keeps fresh, so coordinate-based platform text input
487                    // (iOS caret positioning) can read the caret's window rect.
488                    let handler = TextFieldHandler::new(
489                        state.clone(),
490                        refs.node_id.get(),
491                        line_limits,
492                        crate::text_field_handler::CaretGeometryRefs {
493                            node_origin: refs.node_origin.clone(),
494                            content_offset: refs.content_offset.clone(),
495                            content_y_offset: refs.content_y_offset.clone(),
496                            scroll_offset: refs.scroll_offset.clone(),
497                            style: style.clone(),
498                        },
499                    );
500                    crate::text_field_focus::request_focus(refs.is_focused.clone(), handler);
501
502                    let now = web_time::Instant::now();
503                    let text = state.text();
504                    let pos = crate::text::get_offset_for_position(
505                        &crate::text::AnnotatedString::from(text.as_str()),
506                        &style,
507                        click_x,
508                        click_y,
509                    );
510
511                    // Classify the press into a 1-based tap count by both the
512                    // time since and the distance from the previous press (a tap
513                    // far from the last one starts a fresh single tap, matching
514                    // Android's double-tap slop).
515                    let previous = refs.last_click_pos.get().and_then(|(px, py)| {
516                        let count = refs.click_count.get();
517                        (count > 0).then_some((count, px, py))
518                    });
519                    let elapsed_ms = refs
520                        .last_click_time
521                        .get()
522                        .map(|last| now.duration_since(last).as_millis())
523                        .unwrap_or(u128::MAX);
524                    let tap_count = classify_tap_count(
525                        previous,
526                        elapsed_ms,
527                        event.position.x,
528                        event.position.y,
529                        MULTI_TAP_TIMEOUT_MS,
530                        MULTI_TAP_SLOP_PX,
531                    );
532
533                    // A lone tap that lands INSIDE an existing (non-collapsed)
534                    // selection selects the word under the finger (Android/iOS
535                    // "tap the selection to re-grab a word"). Tapping the SAME
536                    // spot again grows the granularity word → line → paragraph →
537                    // word …, keyed on location so it keeps escalating even when
538                    // the taps arrive too slowly to count as a rapid multi-tap.
539                    // A lone tap elsewhere just places the caret.
540                    let selection = state.selection();
541                    let tap_in_selection =
542                        !selection.collapsed() && pos >= selection.min() && pos <= selection.max();
543                    // Same-spot repeat, independent of the multi-tap timeout:
544                    // within slop of the previous press.
545                    let repeat_in_place = refs
546                        .last_click_pos
547                        .get()
548                        .map(|(px, py)| {
549                            let dx = event.position.x - px;
550                            let dy = event.position.y - py;
551                            dx * dx + dy * dy <= MULTI_TAP_SLOP_PX * MULTI_TAP_SLOP_PX
552                        })
553                        .unwrap_or(false);
554                    let effective_count = resolve_selection_tap_count(
555                        tap_count,
556                        refs.click_count.get(),
557                        tap_in_selection,
558                        repeat_in_place,
559                    );
560
561                    match tap_selection_granularity(effective_count) {
562                        SelectionGranularity::Paragraph => {
563                            // Fourth tap: grow to the whole paragraph.
564                            let (start, end) = find_paragraph_boundaries(&text, pos);
565                            state.edit(|buffer| {
566                                buffer.select(TextRange::new(start, end));
567                            });
568                            refs.drag_anchor.set(Some(start));
569                        }
570                        SelectionGranularity::Line => {
571                            // Triple tap: select the line.
572                            let (line_start, line_end) = find_line_boundaries(&text, pos);
573                            state.edit(|buffer| {
574                                buffer.select(TextRange::new(line_start, line_end));
575                            });
576                            refs.drag_anchor.set(Some(line_start));
577                        }
578                        SelectionGranularity::Word => {
579                            // Double tap (or a tap inside an existing selection):
580                            // select the word.
581                            let (word_start, word_end) = find_word_boundaries(&text, pos);
582                            state.edit(|buffer| {
583                                buffer.select(TextRange::new(word_start, word_end));
584                            });
585                            refs.drag_anchor.set(Some(word_start));
586                        }
587                        SelectionGranularity::Caret => {
588                            // Single tap: place the cursor.
589                            refs.drag_anchor.set(Some(pos));
590                            state.edit(|buffer| {
591                                buffer.place_cursor_before_char(pos);
592                            });
593                        }
594                    }
595
596                    refs.click_count.set(effective_count);
597                    refs.last_click_time.set(Some(now));
598                    refs.last_click_pos
599                        .set(Some((event.position.x, event.position.y)));
600                    event.consume();
601                }
602                PointerEventKind::Move => {
603                    // If we have a drag anchor, extend selection during drag
604                    if let Some(anchor) = refs.drag_anchor.get() {
605                        if *refs.is_focused.borrow() {
606                            let text = state.text();
607                            let current_pos = crate::text::get_offset_for_position(
608                                &crate::text::AnnotatedString::from(text.as_str()),
609                                &style,
610                                click_x,
611                                click_y,
612                            );
613
614                            // Update selection directly (without undo stack push)
615                            state.set_selection(TextRange::new(anchor, current_pos));
616
617                            // Selection change only needs redraw, not layout
618                            crate::request_render_invalidation();
619
620                            event.consume();
621                        }
622                    }
623                }
624                PointerEventKind::Up => {
625                    // Clear drag anchor on mouse up
626                    refs.drag_anchor.set(None);
627                }
628                _ => {}
629            }
630        })
631    }
632
633    /// Creates a node with custom cursor color.
634    pub fn with_cursor_color(mut self, color: Color) -> Self {
635        self.cursor_brush = Brush::solid(color);
636        self
637    }
638
639    /// Sets the focus state.
640    pub fn set_focused(&mut self, focused: bool) {
641        let current = *self.refs.is_focused.borrow();
642        if current != focused {
643            *self.refs.is_focused.borrow_mut() = focused;
644        }
645    }
646
647    /// Returns whether the field is focused.
648    pub fn is_focused(&self) -> bool {
649        *self.refs.is_focused.borrow()
650    }
651
652    /// Returns the is_focused Rc for closure capture.
653    pub fn is_focused_rc(&self) -> Rc<RefCell<bool>> {
654        self.refs.is_focused.clone()
655    }
656
657    /// Returns the content_offset Rc for closure capture.
658    pub fn content_offset_rc(&self) -> Rc<Cell<f32>> {
659        self.refs.content_offset.clone()
660    }
661
662    /// Returns the content_y_offset Rc for closure capture.
663    pub fn content_y_offset_rc(&self) -> Rc<Cell<f32>> {
664        self.refs.content_y_offset.clone()
665    }
666
667    /// Returns the shared cell the field's composited window origin is written
668    /// into (window coordinates of the field node's top-left).
669    ///
670    /// The layout pass writes the field's TRUE on-screen origin here every frame
671    /// — resolved through all ancestor placements (a scrolling `LazyColumn` /
672    /// `vertical_scroll` offsets its items via placement, which the layout tree
673    /// bakes into each node's absolute rect) plus ancestor graphics-layer
674    /// translations. The draw closure reads it back to publish handle metrics,
675    /// so the finger selection/cursor handles anchor at (and their window→offset
676    /// inverse mapping agrees with) the field's real glyphs even while the list
677    /// scrolls. Without this the origin was only ever sampled from the last
678    /// pointer event and went stale the moment the field scrolled.
679    pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
680        self.refs.node_origin.clone()
681    }
682
683    /// Returns the current text.
684    pub fn text(&self) -> String {
685        self.state.text()
686    }
687
688    pub fn style(&self) -> &TextStyle {
689        &self.style
690    }
691
692    /// Returns the current selection.
693    pub fn selection(&self) -> TextRange {
694        self.state.selection()
695    }
696
697    /// Returns the cursor brush for rendering.
698    pub fn cursor_brush(&self) -> Brush {
699        self.cursor_brush.clone()
700    }
701
702    /// Returns the selection brush for rendering selection highlight.
703    pub fn selection_brush(&self) -> Brush {
704        self.selection_brush.clone()
705    }
706
707    /// Inserts text at the current cursor position (for paste operations).
708    pub fn insert_text(&mut self, text: &str) {
709        self.state.edit(|buffer| {
710            buffer.insert(text);
711        });
712    }
713
714    /// Copies the selected text and returns it (for web copy operation).
715    /// Returns None if no selection.
716    pub fn copy_selection(&self) -> Option<String> {
717        self.state.copy_selection()
718    }
719
720    /// Cuts the selected text: copies and deletes it.
721    /// Returns the cut text, or None if no selection.
722    pub fn cut_selection(&mut self) -> Option<String> {
723        let text = self.copy_selection();
724        if text.is_some() {
725            self.state.edit(|buffer| {
726                buffer.delete(buffer.selection());
727            });
728        }
729        text
730    }
731
732    /// Returns a clone of the text field state for use in draw closures.
733    /// This allows reading selection at DRAW time rather than LAYOUT time.
734    pub fn get_state(&self) -> cranpose_foundation::text::TextFieldState {
735        self.state.clone()
736    }
737
738    /// Updates the content offset (padding.left) for accurate click-to-position cursor placement.
739    /// Called from slices collection where padding is known.
740    pub fn set_content_offset(&self, offset: f32) {
741        self.refs.content_offset.set(offset);
742    }
743
744    /// Updates the content Y offset (padding.top) for cursor Y positioning.
745    /// Called from slices collection where padding is known.
746    pub fn set_content_y_offset(&self, offset: f32) {
747        self.refs.content_y_offset.set(offset);
748    }
749
750    /// The wrap width a multi-line field lays its text out at, or `None` when
751    /// the text must not wrap (single-line fields pan horizontally instead).
752    ///
753    /// Multi-line fields wrap at the available content width exactly like the
754    /// render scene builder, so the measured height reflects every wrapped line
755    /// and the field grows to fit its content instead of clipping it.
756    fn wrap_width(&self, available_width: f32) -> Option<f32> {
757        (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
758            .then_some(available_width)
759    }
760
761    /// Measures the text content using node-identity-based caching.
762    ///
763    /// `wrap_width` bounds the layout width so multi-line text wraps; `None`
764    /// measures the natural single-line width (single-line fields, intrinsic
765    /// width queries).
766    fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
767        let text = self.state.text();
768        let node_id = self.refs.node_id.get();
769        let annotated = crate::text::AnnotatedString::from(text.as_str());
770        let metrics = match wrap_width {
771            Some(max_width) => crate::text::measure_text_with_options_for_node(
772                node_id,
773                &annotated,
774                &self.style,
775                crate::text::TextLayoutOptions::default(),
776                Some(max_width),
777            ),
778            None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
779        };
780        self.measured_line_height.set(metrics.line_height);
781        Size {
782            width: metrics.width,
783            height: metrics.height,
784        }
785    }
786
787    /// Updates cached state and returns true if changed.
788    fn update_cached_state(&mut self) -> bool {
789        let value = self.state.value();
790        let text_changed = value.text != self.cached_text;
791        let selection_changed = value.selection != self.cached_selection;
792
793        if text_changed {
794            self.cached_text = value.text;
795        }
796        if selection_changed {
797            self.cached_selection = value.selection;
798        }
799
800        text_changed || selection_changed
801    }
802
803    /// Positions cursor at a given x offset within the text.
804    /// Uses proper text layout hit testing for accurate proportional font support.
805    pub fn position_cursor_at_offset(&self, x_offset: f32) {
806        let text = self.state.text();
807        if text.is_empty() {
808            self.state.edit(|buffer| {
809                buffer.place_cursor_at_start();
810            });
811            return;
812        }
813
814        // Use proper text layout hit testing instead of character-based calculation.
815        // Map the viewport-relative offset into text space by adding the pan offset.
816        let byte_offset = crate::text::get_offset_for_position(
817            &crate::text::AnnotatedString::from(text.as_str()),
818            &self.style,
819            x_offset + self.refs.scroll_offset.get(),
820            0.0,
821        );
822
823        self.state.edit(|buffer| {
824            buffer.place_cursor_before_char(byte_offset);
825        });
826    }
827
828    // NOTE: Key event handling is done via TextFieldHandler::handle_key() which is
829    // registered with the focus system for O(1) dispatch. DO NOT add a handle_key_event()
830    // method here - it would be duplicate code that never gets called.
831}
832
833impl DelegatableNode for TextFieldModifierNode {
834    fn node_state(&self) -> &NodeState {
835        &self.node_state
836    }
837}
838
839impl ModifierNode for TextFieldModifierNode {
840    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
841        // Store node_id for scoped layout invalidation (avoids O(app) global invalidation)
842        self.refs.node_id.set(context.node_id());
843
844        context.invalidate(InvalidationKind::Layout);
845        context.invalidate(InvalidationKind::Draw);
846        context.invalidate(InvalidationKind::Semantics);
847    }
848
849    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
850        Some(self)
851    }
852
853    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
854        Some(self)
855    }
856
857    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
858        Some(self)
859    }
860
861    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
862        Some(self)
863    }
864
865    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
866        Some(self)
867    }
868
869    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
870        Some(self)
871    }
872
873    fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
874        Some(self)
875    }
876
877    fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
878        Some(self)
879    }
880}
881
882impl LayoutModifierNode for TextFieldModifierNode {
883    fn measure(
884        &self,
885        _context: &mut dyn ModifierNodeContext,
886        _measurable: &dyn Measurable,
887        constraints: Constraints,
888    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
889        // Measure the text content, wrapping multi-line fields at the available
890        // width so the field grows to fit every wrapped line instead of
891        // clipping content past the first line.
892        let wrap_width = self.wrap_width(constraints.max_width);
893        // Remember the wrap width so the draw closure can resolve the same
894        // visual (wrapped) lines when placing the caret and selection handles.
895        self.measured_wrap_width.set(wrap_width);
896        let text_size = self.measure_text_content(wrap_width);
897
898        // Add minimum height for empty text (cursor needs space)
899        let min_height = if text_size.height < 1.0 {
900            DEFAULT_LINE_HEIGHT
901        } else {
902            text_size.height
903        };
904
905        // Constrain to provided constraints
906        let width = text_size
907            .width
908            .max(constraints.min_width)
909            .min(constraints.max_width);
910        let height = min_height
911            .max(constraints.min_height)
912            .min(constraints.max_height);
913
914        let size = Size { width, height };
915        self.measured_size.set(size);
916
917        // Refresh the horizontal pan offset so it is up to date for pointer
918        // input and rendering even before the next draw pass runs.
919        let _ = (self.cached_pan_resolver)(size.width);
920
921        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
922    }
923
924    fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
925        self.measure_text_content(None).width
926    }
927
928    fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
929        self.measure_text_content(None).width
930    }
931
932    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
933        self.measure_text_content(self.wrap_width(width))
934            .height
935            .max(DEFAULT_LINE_HEIGHT)
936    }
937
938    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
939        self.measure_text_content(self.wrap_width(width))
940            .height
941            .max(DEFAULT_LINE_HEIGHT)
942    }
943}
944
945impl DrawModifierNode for TextFieldModifierNode {
946    fn draw(&self, _draw_scope: &mut dyn DrawScope) {
947        // No-op: Cursor and selection are rendered via create_draw_closure() which
948        // creates DrawPrimitive::Rect directly. This enables draw-time evaluation
949        // of focus state and cursor blink timing.
950    }
951
952    fn create_draw_closure(
953        &self,
954    ) -> Option<Rc<dyn Fn(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
955    {
956        use cranpose_ui_graphics::DrawPrimitive;
957
958        // Capture state via Rc clone (cheap) for draw-time evaluation
959        let is_focused = self.refs.is_focused.clone();
960        let state = self.state.clone();
961        let content_offset = self.refs.content_offset.clone();
962        let content_y_offset = self.refs.content_y_offset.clone();
963        let cursor_brush = self.cursor_brush.clone();
964        let selection_brush = self.selection_brush.clone();
965        let style = self.style.clone();
966        let cached_line_height = self.measured_line_height.clone();
967        let measured_size = self.measured_size.clone();
968        let measured_wrap_width = self.measured_wrap_width.clone();
969        let node_id = self.refs.node_id.clone();
970        let pan_resolver = self.cached_pan_resolver.clone();
971        let handle_controller = self.handle_controller.clone();
972        let node_origin = self.refs.node_origin.clone();
973        let last_pointer_source = self.refs.last_pointer_source.clone();
974
975        Some(Rc::new(move |size| {
976            // Check focus at DRAW time
977            if !*is_focused.borrow() {
978                // Publish an unfocused snapshot so the composable clears any
979                // finger handles when the field loses focus.
980                if let Some(controller) = &handle_controller {
981                    controller.publish(TextFieldHandleMetrics {
982                        focused: false,
983                        touch: false,
984                        node_origin: node_origin.get(),
985                        padding_left: 0.0,
986                        padding_top: 0.0,
987                        scroll_offset: 0.0,
988                        line_height: cached_line_height.get(),
989                        wrap_width: measured_wrap_width.get(),
990                    });
991                }
992                return vec![];
993            }
994
995            let mut primitives = Vec::new();
996
997            let text = state.text();
998            let selection = state.selection();
999            let padding_left = content_offset.get();
1000            let padding_top = content_y_offset.get();
1001            // Reuse line_height from the most recent layout measurement
1002            // instead of re-measuring the full text.
1003            let line_height = cached_line_height.get();
1004
1005            // Content viewport (excludes padding). Fall back to the node size
1006            // when measurement has not run yet.
1007            let measured = measured_size.get();
1008            let viewport_width = if measured.width > 0.0 {
1009                measured.width
1010            } else {
1011                (size.width - padding_left).max(0.0)
1012            };
1013            let viewport_height = if measured.height > 0.0 {
1014                measured.height
1015            } else {
1016                (size.height - padding_top).max(0.0)
1017            };
1018            // Horizontal pan that keeps the cursor visible (0 for multi-line).
1019            let pan = pan_resolver(viewport_width);
1020
1021            // Publish live geometry so the `BasicTextField` composable can place
1022            // and drive the finger selection handles.
1023            if let Some(controller) = &handle_controller {
1024                controller.publish(TextFieldHandleMetrics {
1025                    focused: true,
1026                    touch: last_pointer_source.get().is_touch_like(),
1027                    node_origin: node_origin.get(),
1028                    padding_left,
1029                    padding_top,
1030                    scroll_offset: pan,
1031                    line_height,
1032                    wrap_width: measured_wrap_width.get(),
1033                });
1034            }
1035            // Everything the field draws (selection, IME underline, cursor)
1036            // is clipped to the content viewport so primitives never extend
1037            // outside the field bounds.
1038            let clip_bounds = cranpose_ui_graphics::Rect {
1039                x: padding_left,
1040                y: padding_top,
1041                width: viewport_width,
1042                height: viewport_height,
1043            };
1044
1045            // Draw selection highlight
1046            if !selection.collapsed() {
1047                let sel_start = selection.min();
1048                let sel_end = selection.max();
1049
1050                let lines: Vec<&str> = text.split('\n').collect();
1051                let mut byte_offset: usize = 0;
1052
1053                for (line_idx, line) in lines.iter().enumerate() {
1054                    let line_start = byte_offset;
1055                    let line_end = byte_offset + line.len();
1056
1057                    if sel_end > line_start && sel_start < line_end {
1058                        let sel_start_in_line = sel_start.saturating_sub(line_start);
1059                        let sel_end_in_line = (sel_end - line_start).min(line.len());
1060
1061                        let sel_start_x = crate::text::measure_text(
1062                            &crate::text::AnnotatedString::from(&line[..sel_start_in_line]),
1063                            &style,
1064                        )
1065                        .width
1066                            + padding_left
1067                            - pan;
1068                        let sel_end_x = crate::text::measure_text(
1069                            &crate::text::AnnotatedString::from(&line[..sel_end_in_line]),
1070                            &style,
1071                        )
1072                        .width
1073                            + padding_left
1074                            - pan;
1075                        let sel_width = sel_end_x - sel_start_x;
1076
1077                        if sel_width > 0.0 {
1078                            let sel_rect = cranpose_ui_graphics::Rect {
1079                                x: sel_start_x,
1080                                y: padding_top + line_idx as f32 * line_height,
1081                                width: sel_width,
1082                                height: line_height,
1083                            };
1084                            if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1085                                primitives.push(DrawPrimitive::Rect {
1086                                    rect: clipped,
1087                                    brush: selection_brush.clone(),
1088                                });
1089                            }
1090                        }
1091                    }
1092                    byte_offset = line_end + 1;
1093                }
1094            }
1095
1096            // Draw composition (IME preedit) underline
1097            // This shows the user which text is being composed by the input method
1098            if let Some(comp_range) = state.composition() {
1099                let comp_start = comp_range.min();
1100                let comp_end = comp_range.max();
1101
1102                if comp_start < comp_end && comp_end <= text.len() {
1103                    let lines: Vec<&str> = text.split('\n').collect();
1104                    let mut byte_offset: usize = 0;
1105
1106                    // Underline color: slightly transparent white/gray
1107                    let underline_brush = cranpose_ui_graphics::Brush::solid(
1108                        cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
1109                    );
1110                    let underline_height: f32 = 2.0;
1111
1112                    for (line_idx, line) in lines.iter().enumerate() {
1113                        let line_start = byte_offset;
1114                        let line_end = byte_offset + line.len();
1115
1116                        // Check if composition overlaps this line
1117                        if comp_end > line_start && comp_start < line_end {
1118                            let comp_start_in_line = comp_start.saturating_sub(line_start);
1119                            let comp_end_in_line = (comp_end - line_start).min(line.len());
1120
1121                            // Clamp to valid UTF-8 boundaries
1122                            let comp_start_in_line = if line.is_char_boundary(comp_start_in_line) {
1123                                comp_start_in_line
1124                            } else {
1125                                0
1126                            };
1127                            let comp_end_in_line = if line.is_char_boundary(comp_end_in_line) {
1128                                comp_end_in_line
1129                            } else {
1130                                line.len()
1131                            };
1132
1133                            let comp_start_x = crate::text::measure_text(
1134                                &crate::text::AnnotatedString::from(&line[..comp_start_in_line]),
1135                                &style,
1136                            )
1137                            .width
1138                                + padding_left
1139                                - pan;
1140                            let comp_end_x = crate::text::measure_text(
1141                                &crate::text::AnnotatedString::from(&line[..comp_end_in_line]),
1142                                &style,
1143                            )
1144                            .width
1145                                + padding_left
1146                                - pan;
1147                            let comp_width = comp_end_x - comp_start_x;
1148
1149                            if comp_width > 0.0 {
1150                                // Draw underline at the bottom of the text line
1151                                let underline_rect = cranpose_ui_graphics::Rect {
1152                                    x: comp_start_x,
1153                                    y: padding_top + (line_idx as f32 + 1.0) * line_height
1154                                        - underline_height,
1155                                    width: comp_width,
1156                                    height: underline_height,
1157                                };
1158                                if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1159                                    primitives.push(DrawPrimitive::Rect {
1160                                        rect: clipped,
1161                                        brush: underline_brush.clone(),
1162                                    });
1163                                }
1164                            }
1165                        }
1166                        byte_offset = line_end + 1;
1167                    }
1168                }
1169            }
1170
1171            // Draw cursor - check visibility at DRAW time for blinking
1172            if crate::cursor_animation::is_cursor_visible() {
1173                let pos = selection.start.min(text.len());
1174                // Resolve the caret's VISUAL (wrapped) line so it lands on the
1175                // same glyph the renderer draws — the field wraps long lines, and
1176                // counting only logical `\n` lines would draw the caret on the
1177                // wrong line (and, with the full logical-line-prefix width, off
1178                // the right edge) while typing/the magnifier stay correct.
1179                let (line_index, line_start) = caret_visual_line_for_offset(
1180                    &text,
1181                    &style,
1182                    node_id.get(),
1183                    measured_wrap_width.get(),
1184                    pos,
1185                );
1186                let cursor_x = crate::text::measure_text(
1187                    &crate::text::AnnotatedString::from(&text[line_start..pos]),
1188                    &style,
1189                )
1190                .width
1191                    + padding_left
1192                    - pan;
1193                let cursor_y = padding_top + line_index as f32 * line_height;
1194
1195                let cursor_rect = cranpose_ui_graphics::Rect {
1196                    x: cursor_x,
1197                    y: cursor_y,
1198                    width: CURSOR_WIDTH,
1199                    height: line_height,
1200                };
1201
1202                if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1203                    primitives.push(DrawPrimitive::Rect {
1204                        rect: clipped,
1205                        brush: cursor_brush.clone(),
1206                    });
1207                }
1208            }
1209
1210            primitives
1211        }))
1212    }
1213}
1214
1215impl SemanticsNode for TextFieldModifierNode {
1216    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1217        let text = self.state.text();
1218        config.content_description = Some(text);
1219        config.is_editable_text = true;
1220        config.text_selection = Some(self.state.selection());
1221    }
1222}
1223
1224impl PointerInputNode for TextFieldModifierNode {
1225    fn on_pointer_event(
1226        &mut self,
1227        _context: &mut dyn ModifierNodeContext,
1228        _event: &PointerEvent,
1229    ) -> bool {
1230        // No-op: All pointer handling is done via pointer_input_handler() closure.
1231        // This follows Jetpack Compose's delegation pattern where the node simply
1232        // forwards to a delegated pointer input handler (see TextFieldDecoratorModifier.kt:741-747).
1233        //
1234        // The cached_handler closure handles:
1235        // - Focus request on Down
1236        // - Cursor positioning
1237        // - Double-click word selection
1238        // - Triple-click select all
1239        // - Drag selection
1240        false
1241    }
1242
1243    fn hit_test(&self, x: f32, y: f32) -> bool {
1244        // Check if point is within measured bounds
1245        let size = self.measured_size.get();
1246        x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1247    }
1248
1249    fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1250        // Return cached handler for pointer input dispatch
1251        Some(self.cached_handler.clone())
1252    }
1253}
1254
1255// ============================================================================
1256// TextFieldElement - Creates and updates TextFieldModifierNode
1257// ============================================================================
1258
1259/// Element that creates and updates `TextFieldModifierNode` instances.
1260///
1261/// This follows the modifier element pattern where the element is responsible for:
1262/// - Creating new nodes (via `create`)
1263/// - Updating existing nodes when properties change (via `update`)
1264/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
1265#[derive(Clone)]
1266pub struct TextFieldElement {
1267    /// The text field state
1268    state: TextFieldState,
1269    /// Text style
1270    style: TextStyle,
1271    /// Cursor color
1272    cursor_color: Color,
1273    /// Line limits configuration
1274    line_limits: TextFieldLineLimits,
1275    /// Channel the node publishes live handle metrics to (finger selection
1276    /// handles). `None` disables handle support.
1277    handle_controller: Option<TextFieldHandleController>,
1278}
1279
1280impl TextFieldElement {
1281    /// Creates a new text field element.
1282    pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1283        Self {
1284            state,
1285            style,
1286            cursor_color: DEFAULT_CURSOR_COLOR,
1287            line_limits: TextFieldLineLimits::default(),
1288            handle_controller: None,
1289        }
1290    }
1291
1292    /// Creates an element with custom cursor color.
1293    pub fn with_cursor_color(mut self, color: Color) -> Self {
1294        self.cursor_color = color;
1295        self
1296    }
1297
1298    /// Creates an element with custom line limits.
1299    pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1300        self.line_limits = line_limits;
1301        self
1302    }
1303
1304    /// Installs the finger-handle metrics channel shared with the composable.
1305    pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1306        self.handle_controller = Some(controller);
1307        self
1308    }
1309}
1310
1311impl std::fmt::Debug for TextFieldElement {
1312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1313        f.debug_struct("TextFieldElement")
1314            .field("text", &self.state.text())
1315            .field("style", &self.style)
1316            .field("cursor_color", &self.cursor_color)
1317            .finish()
1318    }
1319}
1320
1321impl Hash for TextFieldElement {
1322    fn hash<H: Hasher>(&self, state: &mut H) {
1323        // Hash by state Rc pointer identity - matches PartialEq
1324        // This ensures equal elements hash equal (correctness requirement)
1325        std::ptr::hash(std::rc::Rc::as_ptr(&self.state.inner), state);
1326        // Hash cursor color
1327        self.cursor_color.0.to_bits().hash(state);
1328        self.cursor_color.1.to_bits().hash(state);
1329        self.cursor_color.2.to_bits().hash(state);
1330        self.cursor_color.3.to_bits().hash(state);
1331        self.style.render_hash().hash(state);
1332        self.line_limits.hash(state);
1333    }
1334}
1335
1336impl PartialEq for TextFieldElement {
1337    fn eq(&self, other: &Self) -> bool {
1338        // Compare by state identity (same Rc), cursor color, and line limits
1339        // This ensures node reuse when same state is passed, while detecting
1340        // actual changes that require updates
1341        self.state == other.state
1342            && self.style == other.style
1343            && self.cursor_color == other.cursor_color
1344            && self.line_limits == other.line_limits
1345    }
1346}
1347
1348impl Eq for TextFieldElement {}
1349
1350impl ModifierNodeElement for TextFieldElement {
1351    type Node = TextFieldModifierNode;
1352
1353    fn create(&self) -> Self::Node {
1354        let mut node = TextFieldModifierNode::new(self.state.clone(), self.style.clone())
1355            .with_cursor_color(self.cursor_color)
1356            .with_line_limits(self.line_limits);
1357        if let Some(controller) = self.handle_controller.clone() {
1358            node = node.with_handle_controller(controller);
1359        }
1360        node
1361    }
1362
1363    fn update(&self, node: &mut Self::Node) {
1364        // Update the state reference
1365        node.state = self.state.clone();
1366        node.style = self.style.clone();
1367        node.cursor_brush = Brush::solid(self.cursor_color);
1368        node.line_limits = self.line_limits;
1369        node.handle_controller = self.handle_controller.clone();
1370
1371        // Recreate the cached handler with the new state but same refs
1372        node.cached_handler = TextFieldModifierNode::create_handler(
1373            node.state.clone(),
1374            node.refs.clone(),
1375            node.line_limits,
1376            self.style.clone(),
1377        );
1378
1379        // Recreate the pan resolver so it captures the new state/style/limits
1380        node.cached_pan_resolver = TextFieldModifierNode::create_pan_resolver(
1381            node.state.clone(),
1382            node.refs.clone(),
1383            node.line_limits,
1384            self.style.clone(),
1385        );
1386
1387        // Check if content changed and update cache
1388        if node.update_cached_state() {
1389            // Content changed - node will need layout/draw invalidation
1390            // This happens automatically through the modifier reconciliation
1391        }
1392    }
1393
1394    fn capabilities(&self) -> NodeCapabilities {
1395        NodeCapabilities::LAYOUT
1396            | NodeCapabilities::DRAW
1397            | NodeCapabilities::SEMANTICS
1398            | NodeCapabilities::POINTER_INPUT
1399    }
1400
1401    fn always_update(&self) -> bool {
1402        // Always update to capture new state/handler while preserving focus state
1403        true
1404    }
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409    use super::*;
1410    use crate::text::TextStyle;
1411    use cranpose_core::{DefaultScheduler, Runtime};
1412    use std::sync::Arc;
1413
1414    /// Sets up a test runtime and keeps it alive for the duration of the test.
1415    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1416        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1417        f()
1418    }
1419
1420    #[test]
1421    fn text_field_node_creation() {
1422        let _app_context = crate::render_state::app_context_test_scope();
1423        with_test_runtime(|| {
1424            let state = TextFieldState::new("Hello");
1425            let node = TextFieldModifierNode::new(state, TextStyle::default());
1426            assert_eq!(node.text(), "Hello");
1427            assert!(!node.is_focused());
1428        });
1429    }
1430
1431    #[test]
1432    fn text_field_node_focus() {
1433        let _app_context = crate::render_state::app_context_test_scope();
1434        with_test_runtime(|| {
1435            let state = TextFieldState::new("Test");
1436            let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1437            assert!(!node.is_focused());
1438
1439            node.set_focused(true);
1440            assert!(node.is_focused());
1441
1442            node.set_focused(false);
1443            assert!(!node.is_focused());
1444        });
1445    }
1446
1447    #[test]
1448    fn text_field_element_creates_node() {
1449        let _app_context = crate::render_state::app_context_test_scope();
1450        with_test_runtime(|| {
1451            let state = TextFieldState::new("Hello World");
1452            let element = TextFieldElement::new(state, TextStyle::default());
1453
1454            let node = element.create();
1455            assert_eq!(node.text(), "Hello World");
1456        });
1457    }
1458
1459    /// End-to-end guard for the touch-vs-mouse finger-handle pipeline through
1460    /// the real pointer handler and draw closure: a Touch-source press makes the
1461    /// focused field publish `touch = true` (so `SelectionHandles` shows the
1462    /// finger cursor/selection handles and the Copy/Cut/Paste popup), while a
1463    /// Mouse-source press publishes `touch = false` (a clean caret, no handles).
1464    /// The published `touch` flag is exactly what the overlay is gated on, so
1465    /// this pins the source → `last_pointer_source` → metrics link the Android
1466    /// touch handles depend on.
1467    #[test]
1468    fn touch_press_publishes_touch_handle_metrics_but_mouse_does_not() {
1469        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1470        use cranpose_ui_graphics::Point;
1471
1472        let _app_context = crate::render_state::app_context_test_scope();
1473        with_test_runtime(|| {
1474            let state = TextFieldState::new("hello world");
1475            let controller = TextFieldHandleController::new();
1476            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1477                .with_handle_controller(controller.clone());
1478            // Give the field a measured size so the draw closure has geometry.
1479            node.measured_size.set(Size {
1480                width: 120.0,
1481                height: 20.0,
1482            });
1483
1484            let handler = node
1485                .pointer_input_handler()
1486                .expect("field exposes a pointer handler");
1487            let draw = node
1488                .create_draw_closure()
1489                .expect("field exposes a draw closure");
1490            let at = Point { x: 12.0, y: 8.0 };
1491            let size = Size {
1492                width: 120.0,
1493                height: 20.0,
1494            };
1495
1496            // Touch tap: the field focuses and remembers the touch source, so
1497            // its draw closure publishes touch = true.
1498            handler(
1499                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1500            );
1501            let _ = draw(size);
1502            let metrics = controller
1503                .metrics()
1504                .expect("focused field publishes handle metrics");
1505            assert!(metrics.focused, "a tap focuses the field");
1506            assert!(
1507                metrics.touch,
1508                "a touch tap must publish touch = true so the finger handles show"
1509            );
1510
1511            // Mouse tap on the same field: the source flips to mouse, so the
1512            // field publishes touch = false (clean caret, no finger handles).
1513            handler(
1514                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1515            );
1516            let _ = draw(size);
1517            let metrics = controller
1518                .metrics()
1519                .expect("focused field publishes handle metrics");
1520            assert!(
1521                !metrics.touch,
1522                "a mouse tap must publish touch = false (clean caret, no finger handle)"
1523            );
1524
1525            crate::text_field_focus::clear_focus();
1526        });
1527    }
1528
1529    /// A double tap on a word must select that word. This regressed after
1530    /// selection handles began appearing inside `LazyColumn` items in 0.1.39:
1531    /// the cursor handle shown by the first tap overlapped the text line and
1532    /// consumed the second tap. The field's own gesture classification (proven
1533    /// here) is correct — two quick taps at the same spot escalate to a word
1534    /// selection — so the fix is geometric (keep the handle's touch box off the
1535    /// text line; see `selection_handle::handle_shape`).
1536    #[test]
1537    fn double_tap_selects_the_word_under_the_finger() {
1538        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1539        use cranpose_ui_graphics::Point;
1540
1541        let _app_context = crate::render_state::app_context_test_scope();
1542        with_test_runtime(|| {
1543            let state = TextFieldState::new("hello world");
1544            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1545            node.measured_size.set(Size {
1546                width: 200.0,
1547                height: 20.0,
1548            });
1549            let handler = node
1550                .pointer_input_handler()
1551                .expect("field exposes a pointer handler");
1552
1553            // Two touch taps at the same spot, back to back (well within the
1554            // multi-tap timeout and slop): near the start of "hello".
1555            let at = Point { x: 2.0, y: 8.0 };
1556            handler(
1557                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1558            );
1559            handler(
1560                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1561            );
1562
1563            let selection = state.selection();
1564            assert!(
1565                !selection.collapsed(),
1566                "a double tap must produce a (word) selection, got {selection:?}"
1567            );
1568            let selected = &state.text()[selection.min()..selection.max()];
1569            assert_eq!(
1570                selected, "hello",
1571                "double tap should select the whole word under the finger"
1572            );
1573
1574            crate::text_field_focus::clear_focus();
1575        });
1576    }
1577
1578    /// The multi-tap selection granularity ladder (bug 8): repeated in-place taps
1579    /// escalate word → line → paragraph, then cycle back to word. Mirrors mature
1580    /// editors (Android `TextView`, iOS, VS Code).
1581    #[test]
1582    fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1583        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1584        use cranpose_ui_graphics::Point;
1585
1586        let _app_context = crate::render_state::app_context_test_scope();
1587        with_test_runtime(|| {
1588            // Two lines in the first paragraph, a blank line, then a second
1589            // paragraph — so line and paragraph selections differ.
1590            let text = "alpha beta\ngamma delta\n\nsecond para";
1591            let state = TextFieldState::new(text);
1592            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1593                .with_line_limits(TextFieldLineLimits::MultiLine {
1594                    min_lines: 1,
1595                    max_lines: usize::MAX,
1596                });
1597            node.measured_size.set(Size {
1598                width: 400.0,
1599                height: 80.0,
1600            });
1601            let handler = node
1602                .pointer_input_handler()
1603                .expect("field exposes a pointer handler");
1604
1605            // Tap in place on the first line ("alpha").
1606            let at = Point { x: 2.0, y: 4.0 };
1607            let tap = || {
1608                handler(
1609                    PointerEvent::new(PointerEventKind::Down, at, at)
1610                        .with_source(PointerSource::Touch),
1611                );
1612            };
1613            let selected = |state: &TextFieldState| {
1614                let s = state.selection();
1615                state.text()[s.min()..s.max()].to_string()
1616            };
1617
1618            tap(); // 1 → caret
1619            assert!(state.selection().collapsed(), "first tap places the caret");
1620            tap(); // 2 → word
1621            assert_eq!(selected(&state), "alpha", "double tap selects the word");
1622            tap(); // 3 → line
1623            assert_eq!(
1624                selected(&state),
1625                "alpha beta",
1626                "triple tap selects the line"
1627            );
1628            tap(); // 4 → paragraph
1629            assert_eq!(
1630                selected(&state),
1631                "alpha beta\ngamma delta",
1632                "fourth tap grows to the paragraph"
1633            );
1634            tap(); // 5 → cycles back to word
1635            assert_eq!(
1636                selected(&state),
1637                "alpha",
1638                "fifth tap cycles back to the word"
1639            );
1640
1641            crate::text_field_focus::clear_focus();
1642        });
1643    }
1644
1645    /// A single tap that lands inside an existing selection re-grabs the word
1646    /// under the finger (Android/iOS behaviour), rather than collapsing to a
1647    /// caret (bug 8).
1648    #[test]
1649    fn single_tap_inside_selection_selects_the_word() {
1650        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1651        use cranpose_ui_graphics::Point;
1652
1653        let _app_context = crate::render_state::app_context_test_scope();
1654        with_test_runtime(|| {
1655            let state = TextFieldState::new("hello world");
1656            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1657            node.measured_size.set(Size {
1658                width: 200.0,
1659                height: 20.0,
1660            });
1661            let handler = node
1662                .pointer_input_handler()
1663                .expect("field exposes a pointer handler");
1664
1665            // Pre-existing broad selection over the whole text.
1666            state.edit(|buffer| buffer.select(TextRange::new(0, 11)));
1667            assert!(!state.selection().collapsed());
1668
1669            // A lone tap over "hello" (fresh tap count) must select that word,
1670            // not drop the selection.
1671            let at = Point { x: 2.0, y: 8.0 };
1672            handler(
1673                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1674            );
1675
1676            let selection = state.selection();
1677            assert!(
1678                !selection.collapsed(),
1679                "a tap inside a selection must not collapse it, got {selection:?}"
1680            );
1681            assert_eq!(
1682                &state.text()[selection.min()..selection.max()],
1683                "hello",
1684                "a tap inside a selection re-selects the word under the finger"
1685            );
1686
1687            crate::text_field_focus::clear_focus();
1688        });
1689    }
1690
1691    /// Bug (c) at the handler level: repeated taps at the SAME spot inside an
1692    /// existing selection climb the granularity ladder word → line → paragraph →
1693    /// word even when each tap arrives after the multi-tap timeout has lapsed
1694    /// (the growth is keyed on location, not the double-tap timer). Forcing a
1695    /// timeout between taps (clearing `last_click_time`) makes the raw tap count
1696    /// reset to 1 each time, so this exercises the location-based path rather
1697    /// than the rapid-multi-tap path.
1698    #[test]
1699    fn slow_taps_inside_selection_cycle_word_line_paragraph_by_location() {
1700        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1701        use cranpose_ui_graphics::Point;
1702
1703        let _app_context = crate::render_state::app_context_test_scope();
1704        with_test_runtime(|| {
1705            let text = "alpha beta\ngamma delta\n\nsecond para";
1706            let state = TextFieldState::new(text);
1707            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1708                .with_line_limits(TextFieldLineLimits::MultiLine {
1709                    min_lines: 1,
1710                    max_lines: usize::MAX,
1711                });
1712            node.measured_size.set(Size {
1713                width: 400.0,
1714                height: 80.0,
1715            });
1716            let handler = node
1717                .pointer_input_handler()
1718                .expect("field exposes a pointer handler");
1719
1720            // A broad pre-existing selection over the whole text.
1721            state.edit(|buffer| buffer.select(TextRange::new(0, text.len())));
1722
1723            let at = Point { x: 2.0, y: 4.0 };
1724            let selected = |state: &TextFieldState| {
1725                let s = state.selection();
1726                state.text()[s.min()..s.max()].to_string()
1727            };
1728            // Each call forces the multi-tap timer to look expired, so the raw
1729            // tap count resets to 1 while the tap position stays put.
1730            let slow_tap = || {
1731                node.refs.last_click_time.set(None);
1732                handler(
1733                    PointerEvent::new(PointerEventKind::Down, at, at)
1734                        .with_source(PointerSource::Touch),
1735                );
1736            };
1737
1738            slow_tap(); // inside selection → word
1739            assert_eq!(
1740                selected(&state),
1741                "alpha",
1742                "tap inside selection grabs the word"
1743            );
1744            slow_tap(); // same spot → line
1745            assert_eq!(
1746                selected(&state),
1747                "alpha beta",
1748                "same-spot tap grows to the line even after the timeout"
1749            );
1750            slow_tap(); // same spot → paragraph
1751            assert_eq!(
1752                selected(&state),
1753                "alpha beta\ngamma delta",
1754                "same-spot tap grows to the paragraph"
1755            );
1756            slow_tap(); // same spot → cycles back to word
1757            assert_eq!(
1758                selected(&state),
1759                "alpha",
1760                "same-spot tap cycles back to the word"
1761            );
1762
1763            crate::text_field_focus::clear_focus();
1764        });
1765    }
1766
1767    #[test]
1768    fn text_field_element_equality() {
1769        let _app_context = crate::render_state::app_context_test_scope();
1770        with_test_runtime(|| {
1771            let state1 = TextFieldState::new("Hello");
1772            let state2 = TextFieldState::new("Hello"); // Different Rc, same text
1773
1774            let elem1 = TextFieldElement::new(state1.clone(), TextStyle::default());
1775            let elem2 = TextFieldElement::new(state1.clone(), TextStyle::default()); // Same state (Rc identity)
1776            let elem3 = TextFieldElement::new(state2, TextStyle::default()); // Different state
1777
1778            // Elements are equal only when they share the same state Rc
1779            // This ensures proper Eq/Hash contract compliance
1780            assert_eq!(elem1, elem2, "Same state should be equal");
1781            assert_ne!(elem1, elem3, "Different states should not be equal");
1782        });
1783    }
1784
1785    #[test]
1786    fn text_field_element_update_refreshes_existing_node_style() {
1787        let _app_context = crate::render_state::app_context_test_scope();
1788        with_test_runtime(|| {
1789            let state = TextFieldState::new("themed text");
1790            let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
1791                color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
1792                ..crate::text::SpanStyle::default()
1793            });
1794            let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
1795                color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
1796                ..crate::text::SpanStyle::default()
1797            });
1798            let initial = TextFieldElement::new(state.clone(), dark_style);
1799            let updated = TextFieldElement::new(state, light_style.clone());
1800            let mut node = initial.create();
1801
1802            updated.update(&mut node);
1803
1804            assert_eq!(node.text(), "themed text");
1805            assert_eq!(node.style(), &light_style);
1806        });
1807    }
1808
1809    /// A multi-line field must measure the *wrapped* height at the available
1810    /// width, so a long transcript grows the field instead of being clipped to
1811    /// a single line. Regression for the "edits only appear after focus loss"
1812    /// bug where a wrapped OCR transcript rendered only its first line.
1813    #[test]
1814    fn multiline_field_measures_wrapped_height() {
1815        let _app_context = crate::render_state::app_context_test_scope();
1816        with_test_runtime(|| {
1817            let long = "abcd ".repeat(40); // ~200 chars, no explicit newlines
1818            let state = TextFieldState::new(&long);
1819            let node = TextFieldModifierNode::new(state, TextStyle::default());
1820            assert!(
1821                !node.line_limits().is_single_line(),
1822                "default fields are multi-line"
1823            );
1824
1825            let natural = node.measure_text_content(None);
1826            let wrapped = node.measure_text_content(node.wrap_width(20.0));
1827
1828            assert!(
1829                wrapped.height > natural.height,
1830                "wrapped multi-line height {} must exceed the single-line height {}",
1831                wrapped.height,
1832                natural.height
1833            );
1834        });
1835    }
1836
1837    /// Single-line fields pan horizontally instead of wrapping, so they never
1838    /// derive a wrap width even under a narrow constraint.
1839    #[test]
1840    fn single_line_field_never_wraps() {
1841        let _app_context = crate::render_state::app_context_test_scope();
1842        with_test_runtime(|| {
1843            let state = TextFieldState::new("abcd ".repeat(40));
1844            let node = TextFieldModifierNode::new(state, TextStyle::default())
1845                .with_line_limits(TextFieldLineLimits::SingleLine);
1846            assert_eq!(
1847                node.wrap_width(20.0),
1848                None,
1849                "single-line fields must not wrap"
1850            );
1851        });
1852    }
1853
1854    /// Test that cursor draw command position is calculated correctly.
1855    ///
1856    /// This test verifies that when we measure text width for cursor position:
1857    /// 1. The cursor x position = width of text before cursor
1858    /// 2. For text at cursor end, x = full text width
1859    #[test]
1860    fn test_cursor_x_position_calculation() {
1861        let _app_context = crate::render_state::app_context_test_scope();
1862        with_test_runtime(|| {
1863            // Test that text measurement works correctly for cursor positioning
1864            let style = crate::text::TextStyle::default();
1865
1866            // Empty text - cursor should be at x=0
1867            let empty_width =
1868                crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
1869            assert!(
1870                empty_width.abs() < 0.1,
1871                "Empty text should have 0 width, got {}",
1872                empty_width
1873            );
1874
1875            // Non-empty text - cursor at end should be at text width
1876            let hi_width =
1877                crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
1878            assert!(
1879                hi_width > 0.0,
1880                "Text 'Hi' should have positive width: {}",
1881                hi_width
1882            );
1883
1884            // Partial text - cursor after 'H' should be at width of 'H'
1885            let h_width =
1886                crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
1887            assert!(h_width > 0.0, "Text 'H' should have positive width");
1888            assert!(
1889                h_width < hi_width,
1890                "'H' width {} should be less than 'Hi' width {}",
1891                h_width,
1892                hi_width
1893            );
1894
1895            // Verify TextFieldState selection tracks cursor correctly
1896            let state = TextFieldState::new("Hi");
1897            assert_eq!(
1898                state.selection().start,
1899                2,
1900                "Cursor should be at position 2 (end of 'Hi')"
1901            );
1902
1903            // The text before cursor at position 2 in "Hi" is "Hi" itself
1904            let text = state.text();
1905            let cursor_pos = state.selection().start;
1906            let text_before_cursor = &text[..cursor_pos.min(text.len())];
1907            assert_eq!(text_before_cursor, "Hi");
1908
1909            // So cursor x = width of "Hi"
1910            let cursor_x = crate::text::measure_text(
1911                &crate::text::AnnotatedString::from(text_before_cursor),
1912                &style,
1913            )
1914            .width;
1915            assert!(
1916                (cursor_x - hi_width).abs() < 0.1,
1917                "Cursor x {} should equal 'Hi' width {}",
1918                cursor_x,
1919                hi_width
1920            );
1921        });
1922    }
1923
1924    /// Test cursor is created when focused node is in slices.
1925    #[test]
1926    fn test_focused_node_creates_cursor() {
1927        let _app_context = crate::render_state::app_context_test_scope();
1928        with_test_runtime(|| {
1929            let state = TextFieldState::new("Test");
1930            let element = TextFieldElement::new(state.clone(), TextStyle::default());
1931            let node = element.create();
1932
1933            // Initially not focused
1934            assert!(!node.is_focused());
1935
1936            // Set focus
1937            *node.refs.is_focused.borrow_mut() = true;
1938            assert!(node.is_focused());
1939
1940            // Verify the node has correct text
1941            assert_eq!(node.text(), "Test");
1942
1943            // Verify selection is at end
1944            assert_eq!(node.selection().start, 4);
1945        });
1946    }
1947}