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