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