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