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