Skip to main content

cranpose_ui/
text_field_modifier_node.rs

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