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(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
1164    {
1165        use cranpose_ui_graphics::DrawPrimitive;
1166
1167        // Capture state via Rc clone (cheap) for draw-time evaluation
1168        let is_focused = self.refs.is_focused.clone();
1169        let state = self.state.clone();
1170        let content_offset = self.refs.content_offset.clone();
1171        let content_y_offset = self.refs.content_y_offset.clone();
1172        let cursor_brush = self.cursor_brush.clone();
1173        let style = self.style.clone();
1174        let cached_line_height = self.measured_line_height.clone();
1175        let measured_size = self.measured_size.clone();
1176        let measured_wrap_width = self.measured_wrap_width.clone();
1177        let node_id = self.refs.node_id.clone();
1178        let pan_resolver = self.cached_pan_resolver.clone();
1179        let handle_controller = self.handle_controller.clone();
1180        let node_origin = self.refs.node_origin.clone();
1181        let direct_manipulation = self.refs.direct_manipulation.clone();
1182        let press_track = self.refs.press_track.clone();
1183        let gesture_claimed = self.refs.gesture_claimed.clone();
1184
1185        Some(Rc::new(move |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 vec![];
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                            });
1291                        }
1292                    }
1293                }
1294            }
1295
1296            // Draw cursor - check visibility at DRAW time for blinking. The
1297            // caret exists only for a collapsed selection: with a range
1298            // selected the edges are marked by the finger handles, and a
1299            // caret drawn at the range start just thickens the start
1300            // handle's stem.
1301            if selection.collapsed() && crate::cursor_animation::is_cursor_visible() {
1302                let pos = selection.start.min(text.len());
1303                // Resolve the caret's VISUAL (wrapped) line so it lands on the
1304                // same glyph the renderer draws — the field wraps long lines, and
1305                // counting only logical `\n` lines would draw the caret on the
1306                // wrong line (and, with the full logical-line-prefix width, off
1307                // the right edge) while typing/the magnifier stay correct.
1308                // Upstream affinity: a caret placed by a finger at a wrapped
1309                // line's right edge draws at that line's end, not one line
1310                // down at the left edge (matching the cursor handle's anchor).
1311                let (line_index, line_start) = caret_visual_line_for_offset(
1312                    &text,
1313                    &style,
1314                    node_id.get(),
1315                    measured_wrap_width.get(),
1316                    pos,
1317                    crate::text_selection::LineAffinity::Upstream,
1318                );
1319                let cursor_x = crate::text::measure_text(
1320                    &crate::text::AnnotatedString::from(&text[line_start..pos]),
1321                    &style,
1322                )
1323                .width
1324                    + padding_left
1325                    - pan;
1326                // The caret spans the tight glyph box, not the paragraph
1327                // slot — the reference caret's ends ride the glyph extents.
1328                let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1329                let cursor_y = padding_top + line_index as f32 * line_height + box_off;
1330
1331                let cursor_rect = cranpose_ui_graphics::Rect {
1332                    x: cursor_x,
1333                    y: cursor_y,
1334                    width: CURSOR_WIDTH,
1335                    height: box_h,
1336                };
1337
1338                if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1339                    primitives.push(DrawPrimitive::Rect {
1340                        rect: clipped,
1341                        brush: cursor_brush.clone(),
1342                    });
1343                }
1344            }
1345
1346            primitives
1347        }))
1348    }
1349
1350    fn create_behind_draw_closure(
1351        &self,
1352    ) -> Option<Rc<dyn Fn(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
1353    {
1354        use cranpose_ui_graphics::DrawPrimitive;
1355
1356        let is_focused = self.refs.is_focused.clone();
1357        let state = self.state.clone();
1358        let content_offset = self.refs.content_offset.clone();
1359        let content_y_offset = self.refs.content_y_offset.clone();
1360        let selection_brush = self.selection_brush.clone();
1361        let style = self.style.clone();
1362        let cached_line_height = self.measured_line_height.clone();
1363        let measured_size = self.measured_size.clone();
1364        let measured_wrap_width = self.measured_wrap_width.clone();
1365        let node_id = self.refs.node_id.clone();
1366        let pan_resolver = self.cached_pan_resolver.clone();
1367
1368        Some(Rc::new(move |size| {
1369            if !*is_focused.borrow() {
1370                return vec![];
1371            }
1372            let selection = state.selection();
1373            if selection.collapsed() {
1374                return vec![];
1375            }
1376            let text = state.text();
1377            let padding_left = content_offset.get();
1378            let padding_top = content_y_offset.get();
1379            let line_height = cached_line_height.get();
1380            let (viewport_width, viewport_height) =
1381                content_viewport(measured_size.get(), size, padding_left, padding_top);
1382            let pan = pan_resolver(viewport_width);
1383            let clip_bounds = cranpose_ui_graphics::Rect {
1384                x: padding_left,
1385                y: padding_top,
1386                width: viewport_width,
1387                height: viewport_height,
1388            };
1389
1390            // Highlight per VISUAL (wrapped) line so it lands on the same
1391            // glyphs the renderer draws — BENEATH them (the reference keeps
1392            // selected glyphs unblended white over the tint).
1393            let mut primitives = Vec::new();
1394            // Highlight rects hug the tight glyph box of each line — the
1395            // reference selection shows GAPS between lines when the
1396            // paragraph line height exceeds the natural text height.
1397            let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1398            for sel_rect in range_visual_line_rects(
1399                &text,
1400                &style,
1401                node_id.get(),
1402                measured_wrap_width.get(),
1403                padding_left,
1404                padding_top,
1405                pan,
1406                line_height,
1407                selection.min(),
1408                selection.max(),
1409            ) {
1410                let sel_rect = cranpose_ui_graphics::Rect {
1411                    y: sel_rect.y + box_off,
1412                    height: box_h,
1413                    ..sel_rect
1414                };
1415                if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1416                    primitives.push(DrawPrimitive::Rect {
1417                        rect: clipped,
1418                        brush: selection_brush.clone(),
1419                    });
1420                }
1421            }
1422            primitives
1423        }))
1424    }
1425}
1426
1427impl SemanticsNode for TextFieldModifierNode {
1428    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1429        let text = self.state.text();
1430        config.content_description = Some(text);
1431        config.is_editable_text = true;
1432        config.text_selection = Some(self.state.selection());
1433    }
1434}
1435
1436impl PointerInputNode for TextFieldModifierNode {
1437    fn on_pointer_event(
1438        &mut self,
1439        _context: &mut dyn ModifierNodeContext,
1440        _event: &PointerEvent,
1441    ) -> bool {
1442        // No-op: All pointer handling is done via pointer_input_handler() closure.
1443        // This follows Jetpack Compose's delegation pattern where the node simply
1444        // forwards to a delegated pointer input handler (see TextFieldDecoratorModifier.kt:741-747).
1445        //
1446        // The cached_handler closure handles:
1447        // - Focus request on Down
1448        // - Cursor positioning
1449        // - Double-click word selection
1450        // - Triple-click select all
1451        // - Drag selection
1452        false
1453    }
1454
1455    fn hit_test(&self, x: f32, y: f32) -> bool {
1456        // Check if point is within measured bounds
1457        let size = self.measured_size.get();
1458        x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1459    }
1460
1461    fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1462        // Return cached handler for pointer input dispatch
1463        Some(self.cached_handler.clone())
1464    }
1465}
1466
1467// ============================================================================
1468// TextFieldElement - Creates and updates TextFieldModifierNode
1469// ============================================================================
1470
1471/// Element that creates and updates `TextFieldModifierNode` instances.
1472///
1473/// This follows the modifier element pattern where the element is responsible for:
1474/// - Creating new nodes (via `create`)
1475/// - Updating existing nodes when properties change (via `update`)
1476/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
1477#[derive(Clone)]
1478pub struct TextFieldElement {
1479    /// The text field state
1480    state: TextFieldState,
1481    /// Text style
1482    style: TextStyle,
1483    /// Cursor color
1484    cursor_color: Color,
1485    /// Line limits configuration
1486    line_limits: TextFieldLineLimits,
1487    /// Channel the node publishes live handle metrics to (finger selection
1488    /// handles). `None` disables handle support.
1489    handle_controller: Option<TextFieldHandleController>,
1490}
1491
1492impl TextFieldElement {
1493    /// Creates a new text field element.
1494    pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1495        Self {
1496            state,
1497            style,
1498            cursor_color: DEFAULT_CURSOR_COLOR,
1499            line_limits: TextFieldLineLimits::default(),
1500            handle_controller: None,
1501        }
1502    }
1503
1504    /// Creates an element with custom cursor color.
1505    pub fn with_cursor_color(mut self, color: Color) -> Self {
1506        self.cursor_color = color;
1507        self
1508    }
1509
1510    /// Creates an element with custom line limits.
1511    pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1512        self.line_limits = line_limits;
1513        self
1514    }
1515
1516    /// Installs the finger-handle metrics channel shared with the composable.
1517    pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1518        self.handle_controller = Some(controller);
1519        self
1520    }
1521}
1522
1523impl std::fmt::Debug for TextFieldElement {
1524    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1525        f.debug_struct("TextFieldElement")
1526            .field("text", &self.state.text())
1527            .field("style", &self.style)
1528            .field("cursor_color", &self.cursor_color)
1529            .finish()
1530    }
1531}
1532
1533impl Hash for TextFieldElement {
1534    fn hash<H: Hasher>(&self, state: &mut H) {
1535        // Hash by state Rc pointer identity - matches PartialEq
1536        // This ensures equal elements hash equal (correctness requirement)
1537        std::ptr::hash(std::rc::Rc::as_ptr(&self.state.inner), state);
1538        // Hash cursor color
1539        self.cursor_color.0.to_bits().hash(state);
1540        self.cursor_color.1.to_bits().hash(state);
1541        self.cursor_color.2.to_bits().hash(state);
1542        self.cursor_color.3.to_bits().hash(state);
1543        self.style.render_hash().hash(state);
1544        self.line_limits.hash(state);
1545    }
1546}
1547
1548impl PartialEq for TextFieldElement {
1549    fn eq(&self, other: &Self) -> bool {
1550        // Compare by state identity (same Rc), cursor color, and line limits
1551        // This ensures node reuse when same state is passed, while detecting
1552        // actual changes that require updates
1553        self.state == other.state
1554            && self.style == other.style
1555            && self.cursor_color == other.cursor_color
1556            && self.line_limits == other.line_limits
1557    }
1558}
1559
1560impl Eq for TextFieldElement {}
1561
1562impl ModifierNodeElement for TextFieldElement {
1563    type Node = TextFieldModifierNode;
1564
1565    fn create(&self) -> Self::Node {
1566        let mut node = TextFieldModifierNode::new(self.state.clone(), self.style.clone())
1567            .with_cursor_color(self.cursor_color)
1568            .with_line_limits(self.line_limits);
1569        if let Some(controller) = self.handle_controller.clone() {
1570            node = node.with_handle_controller(controller);
1571        }
1572        node
1573    }
1574
1575    fn update(&self, node: &mut Self::Node) {
1576        // Update the state reference
1577        node.state = self.state.clone();
1578        node.style = self.style.clone();
1579        node.cursor_brush = Brush::solid(self.cursor_color);
1580        node.line_limits = self.line_limits;
1581        node.handle_controller = self.handle_controller.clone();
1582
1583        // Recreate the cached handler with the new state but same refs
1584        node.cached_handler = TextFieldModifierNode::create_handler(
1585            node.state.clone(),
1586            node.refs.clone(),
1587            node.line_limits,
1588            self.style.clone(),
1589        );
1590
1591        // Recreate the pan resolver so it captures the new state/style/limits
1592        node.cached_pan_resolver = TextFieldModifierNode::create_pan_resolver(
1593            node.state.clone(),
1594            node.refs.clone(),
1595            node.line_limits,
1596            self.style.clone(),
1597        );
1598
1599        // Check if content changed and update cache
1600        if node.update_cached_state() {
1601            // Content changed - node will need layout/draw invalidation
1602            // This happens automatically through the modifier reconciliation
1603        }
1604    }
1605
1606    fn capabilities(&self) -> NodeCapabilities {
1607        NodeCapabilities::LAYOUT
1608            | NodeCapabilities::DRAW
1609            | NodeCapabilities::SEMANTICS
1610            | NodeCapabilities::POINTER_INPUT
1611    }
1612
1613    fn always_update(&self) -> bool {
1614        // Always update to capture new state/handler while preserving focus state
1615        true
1616    }
1617}
1618
1619#[cfg(test)]
1620mod tests {
1621    use super::*;
1622    use crate::text::TextStyle;
1623    use cranpose_core::{DefaultScheduler, Runtime};
1624    use std::sync::Arc;
1625
1626    /// Sets up a test runtime and keeps it alive for the duration of the test.
1627    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1628        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1629        f()
1630    }
1631
1632    #[test]
1633    fn text_field_node_creation() {
1634        let _app_context = crate::render_state::app_context_test_scope();
1635        with_test_runtime(|| {
1636            let state = TextFieldState::new("Hello");
1637            let node = TextFieldModifierNode::new(state, TextStyle::default());
1638            assert_eq!(node.text(), "Hello");
1639            assert!(!node.is_focused());
1640        });
1641    }
1642
1643    // Regression: a selection (or preedit) whose logical line sits *below* a
1644    // soft-wrapped line must highlight on the correct VISUAL line. The old
1645    // logical-`\n` split placed it one line too high whenever a line above
1646    // wrapped — the reported "correct x, wrong y line" iOS selection bug.
1647    #[test]
1648    fn selection_rects_follow_wrapped_visual_lines() {
1649        let _app_context = crate::render_state::app_context_test_scope();
1650        // Monospaced test measurer: 14.0 * 0.6 = 8.4 px per char. Wrap width 30
1651        // fits 3 chars (25.2) but not 4 (33.6), so "aaaaa" wraps to "aaa"/"aa".
1652        let text = "aaaaa\nbb";
1653        let style = TextStyle::default();
1654        let line_height = 10.0_f32;
1655
1656        // Select "bb" — logical line 1, but VISUAL line 2 (two visual lines
1657        // above it: "aaa", "aa").
1658        let rects = range_visual_line_rects(
1659            text,
1660            &style,
1661            None,
1662            Some(30.0),
1663            0.0,
1664            0.0,
1665            0.0,
1666            line_height,
1667            6,
1668            8,
1669        );
1670        assert_eq!(rects.len(), 1, "one visual line touched, got {rects:?}");
1671        assert_eq!(
1672            rects[0].y,
1673            2.0 * line_height,
1674            "highlight must land on visual line 2, not logical line 1"
1675        );
1676        assert!(rects[0].width > 0.0);
1677
1678        // A selection spanning the wrap boundary produces one rect per visual
1679        // line, at consecutive y positions.
1680        let spanning = range_visual_line_rects(
1681            text,
1682            &style,
1683            None,
1684            Some(30.0),
1685            0.0,
1686            0.0,
1687            0.0,
1688            line_height,
1689            0,
1690            5,
1691        );
1692        assert_eq!(spanning.len(), 2, "wrapped line spans two visual rows");
1693        assert_eq!(spanning[0].y, 0.0);
1694        assert_eq!(spanning[1].y, line_height);
1695    }
1696
1697    // Regression: a finger tap must resolve to the byte offset on the VISUAL
1698    // (wrapped) line under the finger. The measurer's plain get_offset_for_position
1699    // maps `y` through logical `\n` lines only, so on wrapped text the caret
1700    // landed below the finger — the reported "taps miss the y coordinate" bug.
1701    #[test]
1702    fn tap_resolves_offset_on_wrapped_visual_line() {
1703        let _app_context = crate::render_state::app_context_test_scope();
1704        // Same fixture: wrap width 30 splits "aaaaa" into "aaa"/"aa"; "bb" is the
1705        // third visual line. line_height 10 → line 2 spans y in [20, 30).
1706        let text = "aaaaa\nbb";
1707        let style = TextStyle::default();
1708        let line_height = 10.0_f32;
1709
1710        // Tap on visual line 2 ("bb") must land in bytes 6..=8, not in the
1711        // wrapped first logical line.
1712        let off = crate::text::offset_for_position_wrapped(
1713            text,
1714            &style,
1715            None,
1716            Some(30.0),
1717            line_height,
1718            8.0,
1719            22.0,
1720        );
1721        assert!(
1722            (6..=8).contains(&off),
1723            "tap on visual line 'bb' resolved to {off}, expected 6..=8"
1724        );
1725
1726        // Tap on visual line 1 (the "aa" continuation of the first logical line)
1727        // must land in bytes 3..=5.
1728        let off1 = crate::text::offset_for_position_wrapped(
1729            text,
1730            &style,
1731            None,
1732            Some(30.0),
1733            line_height,
1734            4.0,
1735            12.0,
1736        );
1737        assert!(
1738            (3..=5).contains(&off1),
1739            "tap on wrapped 'aa' resolved to {off1}, expected 3..=5"
1740        );
1741
1742        // Single-line (no wrap width): degrades to the one logical line.
1743        let off2 = crate::text::offset_for_position_wrapped(
1744            "hello",
1745            &style,
1746            None,
1747            None,
1748            line_height,
1749            0.0,
1750            0.0,
1751        );
1752        assert_eq!(off2, 0);
1753    }
1754
1755    #[test]
1756    fn text_field_node_focus() {
1757        let _app_context = crate::render_state::app_context_test_scope();
1758        with_test_runtime(|| {
1759            let state = TextFieldState::new("Test");
1760            let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1761            assert!(!node.is_focused());
1762
1763            node.set_focused(true);
1764            assert!(node.is_focused());
1765
1766            node.set_focused(false);
1767            assert!(!node.is_focused());
1768        });
1769    }
1770
1771    #[test]
1772    fn text_field_element_creates_node() {
1773        let _app_context = crate::render_state::app_context_test_scope();
1774        with_test_runtime(|| {
1775            let state = TextFieldState::new("Hello World");
1776            let element = TextFieldElement::new(state, TextStyle::default());
1777
1778            let node = element.create();
1779            assert_eq!(node.text(), "Hello World");
1780        });
1781    }
1782
1783    /// End-to-end guard for source-independent direct manipulation through the
1784    /// real pointer handler and draw closure. Keyboard-only focus keeps a clean
1785    /// caret; touch, mouse, and stylus presses publish handles and a continuous
1786    /// press stream for long-press → slide-to-menu.
1787    #[test]
1788    fn every_primary_pointer_source_publishes_direct_manipulation_metrics() {
1789        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1790        use cranpose_ui_graphics::Point;
1791
1792        let _app_context = crate::render_state::app_context_test_scope();
1793        with_test_runtime(|| {
1794            let state = TextFieldState::new("hello world");
1795            let controller = TextFieldHandleController::new();
1796            let mut node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1797                .with_handle_controller(controller.clone());
1798            // Give the field a measured size so the draw closure has geometry.
1799            node.measured_size.set(Size {
1800                width: 120.0,
1801                height: 20.0,
1802            });
1803
1804            let handler = node
1805                .pointer_input_handler()
1806                .expect("field exposes a pointer handler");
1807            let draw = node
1808                .create_draw_closure()
1809                .expect("field exposes a draw closure");
1810            let at = Point { x: 12.0, y: 8.0 };
1811            let size = Size {
1812                width: 120.0,
1813                height: 20.0,
1814            };
1815
1816            node.set_focused(true);
1817            let _ = draw(size);
1818            let keyboard_metrics = controller
1819                .metrics()
1820                .expect("focused field publishes handle metrics");
1821            assert!(!keyboard_metrics.direct_manipulation);
1822
1823            handler(
1824                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1825            );
1826            let _ = draw(size);
1827            let metrics = controller
1828                .metrics()
1829                .expect("focused field publishes handle metrics");
1830            assert!(metrics.focused, "a tap focuses the field");
1831            assert!(
1832                metrics.direct_manipulation,
1833                "a touch tap must expose direct-manipulation handles"
1834            );
1835            assert!(metrics.press.is_some(), "touch must publish the live press");
1836
1837            handler(
1838                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1839            );
1840            let _ = draw(size);
1841            let metrics = controller
1842                .metrics()
1843                .expect("focused field publishes handle metrics");
1844            assert!(
1845                metrics.direct_manipulation,
1846                "a mouse tap must expose the same direct-manipulation handles"
1847            );
1848            assert!(metrics.press.is_some(), "mouse must publish the live press");
1849
1850            handler(
1851                PointerEvent::new(PointerEventKind::Down, at, at)
1852                    .with_source(PointerSource::Stylus),
1853            );
1854            let _ = draw(size);
1855            let metrics = controller
1856                .metrics()
1857                .expect("focused field publishes handle metrics");
1858            assert!(
1859                metrics.direct_manipulation,
1860                "a stylus contact must expose the same direct-manipulation handles"
1861            );
1862            assert!(
1863                metrics.press.is_some(),
1864                "stylus must publish the live press"
1865            );
1866
1867            crate::text_field_focus::clear_focus();
1868        });
1869    }
1870
1871    /// A double tap on a word must select that word. This regressed after
1872    /// selection handles began appearing inside `LazyColumn` items in 0.1.39:
1873    /// the cursor handle shown by the first tap overlapped the text line and
1874    /// consumed the second tap. The field's own gesture classification (proven
1875    /// here) is correct — two quick taps at the same spot escalate to a word
1876    /// selection — so the fix is geometric (keep the handle's touch box off the
1877    /// text line; see `selection_handle::handle_shape`).
1878    #[test]
1879    fn double_tap_selects_the_word_under_the_finger() {
1880        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1881        use cranpose_ui_graphics::Point;
1882
1883        let _app_context = crate::render_state::app_context_test_scope();
1884        with_test_runtime(|| {
1885            let state = TextFieldState::new("hello world");
1886            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1887            node.measured_size.set(Size {
1888                width: 200.0,
1889                height: 20.0,
1890            });
1891            let handler = node
1892                .pointer_input_handler()
1893                .expect("field exposes a pointer handler");
1894
1895            // Two touch taps at the same spot, back to back (well within the
1896            // multi-tap timeout and slop): near the start of "hello".
1897            let at = Point { x: 2.0, y: 8.0 };
1898            handler(
1899                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1900            );
1901            handler(
1902                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1903            );
1904
1905            let selection = state.selection();
1906            assert!(
1907                !selection.collapsed(),
1908                "a double tap must produce a (word) selection, got {selection:?}"
1909            );
1910            let selected = &state.text()[selection.min()..selection.max()];
1911            assert_eq!(
1912                selected, "hello",
1913                "double tap should select the whole word under the finger"
1914            );
1915
1916            crate::text_field_focus::clear_focus();
1917        });
1918    }
1919
1920    /// The multi-tap selection granularity ladder (bug 8): repeated in-place taps
1921    /// escalate word → line → paragraph, then cycle back to word. Mirrors mature
1922    /// editors (Android `TextView`, iOS, VS Code).
1923    #[test]
1924    fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1925        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1926        use cranpose_ui_graphics::Point;
1927
1928        let _app_context = crate::render_state::app_context_test_scope();
1929        with_test_runtime(|| {
1930            // Two lines in the first paragraph, a blank line, then a second
1931            // paragraph — so line and paragraph selections differ.
1932            let text = "alpha beta\ngamma delta\n\nsecond para";
1933            let state = TextFieldState::new(text);
1934            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1935                .with_line_limits(TextFieldLineLimits::MultiLine {
1936                    min_lines: 1,
1937                    max_lines: usize::MAX,
1938                });
1939            node.measured_size.set(Size {
1940                width: 400.0,
1941                height: 80.0,
1942            });
1943            let handler = node
1944                .pointer_input_handler()
1945                .expect("field exposes a pointer handler");
1946
1947            // Tap in place on the first line ("alpha").
1948            let at = Point { x: 2.0, y: 4.0 };
1949            let tap = || {
1950                handler(
1951                    PointerEvent::new(PointerEventKind::Down, at, at)
1952                        .with_source(PointerSource::Touch),
1953                );
1954            };
1955            let selected = |state: &TextFieldState| {
1956                let s = state.selection();
1957                state.text()[s.min()..s.max()].to_string()
1958            };
1959
1960            tap(); // 1 → caret
1961            assert!(state.selection().collapsed(), "first tap places the caret");
1962            tap(); // 2 → word
1963            assert_eq!(selected(&state), "alpha", "double tap selects the word");
1964            tap(); // 3 → line
1965            assert_eq!(
1966                selected(&state),
1967                "alpha beta",
1968                "triple tap selects the line"
1969            );
1970            tap(); // 4 → paragraph
1971            assert_eq!(
1972                selected(&state),
1973                "alpha beta\ngamma delta",
1974                "fourth tap grows to the paragraph"
1975            );
1976            tap(); // 5 → cycles back to word
1977            assert_eq!(
1978                selected(&state),
1979                "alpha",
1980                "fifth tap cycles back to the word"
1981            );
1982
1983            crate::text_field_focus::clear_focus();
1984        });
1985    }
1986
1987    /// A single tap that lands inside an existing selection re-grabs the word
1988    /// under the finger (Android/iOS behaviour), rather than collapsing to a
1989    /// caret (bug 8).
1990    #[test]
1991    fn single_tap_inside_selection_selects_the_word() {
1992        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1993        use cranpose_ui_graphics::Point;
1994
1995        let _app_context = crate::render_state::app_context_test_scope();
1996        with_test_runtime(|| {
1997            let state = TextFieldState::new("hello world");
1998            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1999            node.measured_size.set(Size {
2000                width: 200.0,
2001                height: 20.0,
2002            });
2003            let handler = node
2004                .pointer_input_handler()
2005                .expect("field exposes a pointer handler");
2006
2007            // Pre-existing broad selection over the whole text.
2008            state.edit(|buffer| buffer.select(TextRange::new(0, 11)));
2009            assert!(!state.selection().collapsed());
2010
2011            // A lone tap over "hello" (fresh tap count) must select that word,
2012            // not drop the selection.
2013            let at = Point { x: 2.0, y: 8.0 };
2014            handler(
2015                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
2016            );
2017
2018            let selection = state.selection();
2019            assert!(
2020                !selection.collapsed(),
2021                "a tap inside a selection must not collapse it, got {selection:?}"
2022            );
2023            assert_eq!(
2024                &state.text()[selection.min()..selection.max()],
2025                "hello",
2026                "a tap inside a selection re-selects the word under the finger"
2027            );
2028
2029            crate::text_field_focus::clear_focus();
2030        });
2031    }
2032
2033    /// Bug (c) at the handler level: repeated taps at the SAME spot inside an
2034    /// existing selection climb the granularity ladder word → line → paragraph →
2035    /// word even when each tap arrives after the multi-tap timeout has lapsed
2036    /// (the growth is keyed on location, not the double-tap timer). Forcing a
2037    /// timeout between taps (clearing `last_click_time`) makes the raw tap count
2038    /// reset to 1 each time, so this exercises the location-based path rather
2039    /// than the rapid-multi-tap path.
2040    #[test]
2041    fn slow_taps_inside_selection_cycle_word_line_paragraph_by_location() {
2042        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
2043        use cranpose_ui_graphics::Point;
2044
2045        let _app_context = crate::render_state::app_context_test_scope();
2046        with_test_runtime(|| {
2047            let text = "alpha beta\ngamma delta\n\nsecond para";
2048            let state = TextFieldState::new(text);
2049            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
2050                .with_line_limits(TextFieldLineLimits::MultiLine {
2051                    min_lines: 1,
2052                    max_lines: usize::MAX,
2053                });
2054            node.measured_size.set(Size {
2055                width: 400.0,
2056                height: 80.0,
2057            });
2058            let handler = node
2059                .pointer_input_handler()
2060                .expect("field exposes a pointer handler");
2061
2062            // A broad pre-existing selection over the whole text.
2063            state.edit(|buffer| buffer.select(TextRange::new(0, text.len())));
2064
2065            let at = Point { x: 2.0, y: 4.0 };
2066            let selected = |state: &TextFieldState| {
2067                let s = state.selection();
2068                state.text()[s.min()..s.max()].to_string()
2069            };
2070            // Each call forces the multi-tap timer to look expired, so the raw
2071            // tap count resets to 1 while the tap position stays put.
2072            let slow_tap = || {
2073                node.refs.last_click_time.set(None);
2074                handler(
2075                    PointerEvent::new(PointerEventKind::Down, at, at)
2076                        .with_source(PointerSource::Touch),
2077                );
2078            };
2079
2080            slow_tap(); // inside selection → word
2081            assert_eq!(
2082                selected(&state),
2083                "alpha",
2084                "tap inside selection grabs the word"
2085            );
2086            slow_tap(); // same spot → line
2087            assert_eq!(
2088                selected(&state),
2089                "alpha beta",
2090                "same-spot tap grows to the line even after the timeout"
2091            );
2092            slow_tap(); // same spot → paragraph
2093            assert_eq!(
2094                selected(&state),
2095                "alpha beta\ngamma delta",
2096                "same-spot tap grows to the paragraph"
2097            );
2098            slow_tap(); // same spot → cycles back to word
2099            assert_eq!(
2100                selected(&state),
2101                "alpha",
2102                "same-spot tap cycles back to the word"
2103            );
2104
2105            crate::text_field_focus::clear_focus();
2106        });
2107    }
2108
2109    #[test]
2110    fn text_field_element_equality() {
2111        let _app_context = crate::render_state::app_context_test_scope();
2112        with_test_runtime(|| {
2113            let state1 = TextFieldState::new("Hello");
2114            let state2 = TextFieldState::new("Hello"); // Different Rc, same text
2115
2116            let elem1 = TextFieldElement::new(state1.clone(), TextStyle::default());
2117            let elem2 = TextFieldElement::new(state1.clone(), TextStyle::default()); // Same state (Rc identity)
2118            let elem3 = TextFieldElement::new(state2, TextStyle::default()); // Different state
2119
2120            // Elements are equal only when they share the same state Rc
2121            // This ensures proper Eq/Hash contract compliance
2122            assert_eq!(elem1, elem2, "Same state should be equal");
2123            assert_ne!(elem1, elem3, "Different states should not be equal");
2124        });
2125    }
2126
2127    #[test]
2128    fn text_field_element_update_refreshes_existing_node_style() {
2129        let _app_context = crate::render_state::app_context_test_scope();
2130        with_test_runtime(|| {
2131            let state = TextFieldState::new("themed text");
2132            let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
2133                color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
2134                ..crate::text::SpanStyle::default()
2135            });
2136            let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
2137                color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
2138                ..crate::text::SpanStyle::default()
2139            });
2140            let initial = TextFieldElement::new(state.clone(), dark_style);
2141            let updated = TextFieldElement::new(state, light_style.clone());
2142            let mut node = initial.create();
2143
2144            updated.update(&mut node);
2145
2146            assert_eq!(node.text(), "themed text");
2147            assert_eq!(node.style(), &light_style);
2148        });
2149    }
2150
2151    /// A multi-line field must measure the *wrapped* height at the available
2152    /// width, so a long transcript grows the field instead of being clipped to
2153    /// a single line. Regression for the "edits only appear after focus loss"
2154    /// bug where a wrapped OCR transcript rendered only its first line.
2155    #[test]
2156    fn multiline_field_measures_wrapped_height() {
2157        let _app_context = crate::render_state::app_context_test_scope();
2158        with_test_runtime(|| {
2159            let long = "abcd ".repeat(40); // ~200 chars, no explicit newlines
2160            let state = TextFieldState::new(&long);
2161            let node = TextFieldModifierNode::new(state, TextStyle::default());
2162            assert!(
2163                !node.line_limits().is_single_line(),
2164                "default fields are multi-line"
2165            );
2166
2167            let natural = node.measure_text_content(None);
2168            let wrapped = node.measure_text_content(node.wrap_width(20.0));
2169
2170            assert!(
2171                wrapped.height > natural.height,
2172                "wrapped multi-line height {} must exceed the single-line height {}",
2173                wrapped.height,
2174                natural.height
2175            );
2176        });
2177    }
2178
2179    /// Single-line fields pan horizontally instead of wrapping, so they never
2180    /// derive a wrap width even under a narrow constraint.
2181    #[test]
2182    fn single_line_field_never_wraps() {
2183        let _app_context = crate::render_state::app_context_test_scope();
2184        with_test_runtime(|| {
2185            let state = TextFieldState::new("abcd ".repeat(40));
2186            let node = TextFieldModifierNode::new(state, TextStyle::default())
2187                .with_line_limits(TextFieldLineLimits::SingleLine);
2188            assert_eq!(
2189                node.wrap_width(20.0),
2190                None,
2191                "single-line fields must not wrap"
2192            );
2193        });
2194    }
2195
2196    /// Test that cursor draw command position is calculated correctly.
2197    ///
2198    /// This test verifies that when we measure text width for cursor position:
2199    /// 1. The cursor x position = width of text before cursor
2200    /// 2. For text at cursor end, x = full text width
2201    #[test]
2202    fn test_cursor_x_position_calculation() {
2203        let _app_context = crate::render_state::app_context_test_scope();
2204        with_test_runtime(|| {
2205            // Test that text measurement works correctly for cursor positioning
2206            let style = crate::text::TextStyle::default();
2207
2208            // Empty text - cursor should be at x=0
2209            let empty_width =
2210                crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
2211            assert!(
2212                empty_width.abs() < 0.1,
2213                "Empty text should have 0 width, got {}",
2214                empty_width
2215            );
2216
2217            // Non-empty text - cursor at end should be at text width
2218            let hi_width =
2219                crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
2220            assert!(
2221                hi_width > 0.0,
2222                "Text 'Hi' should have positive width: {}",
2223                hi_width
2224            );
2225
2226            // Partial text - cursor after 'H' should be at width of 'H'
2227            let h_width =
2228                crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
2229            assert!(h_width > 0.0, "Text 'H' should have positive width");
2230            assert!(
2231                h_width < hi_width,
2232                "'H' width {} should be less than 'Hi' width {}",
2233                h_width,
2234                hi_width
2235            );
2236
2237            // Verify TextFieldState selection tracks cursor correctly
2238            let state = TextFieldState::new("Hi");
2239            assert_eq!(
2240                state.selection().start,
2241                2,
2242                "Cursor should be at position 2 (end of 'Hi')"
2243            );
2244
2245            // The text before cursor at position 2 in "Hi" is "Hi" itself
2246            let text = state.text();
2247            let cursor_pos = state.selection().start;
2248            let text_before_cursor = &text[..cursor_pos.min(text.len())];
2249            assert_eq!(text_before_cursor, "Hi");
2250
2251            // So cursor x = width of "Hi"
2252            let cursor_x = crate::text::measure_text(
2253                &crate::text::AnnotatedString::from(text_before_cursor),
2254                &style,
2255            )
2256            .width;
2257            assert!(
2258                (cursor_x - hi_width).abs() < 0.1,
2259                "Cursor x {} should equal 'Hi' width {}",
2260                cursor_x,
2261                hi_width
2262            );
2263        });
2264    }
2265
2266    /// Test cursor is created when focused node is in slices.
2267    #[test]
2268    fn test_focused_node_creates_cursor() {
2269        let _app_context = crate::render_state::app_context_test_scope();
2270        with_test_runtime(|| {
2271            let state = TextFieldState::new("Test");
2272            let element = TextFieldElement::new(state.clone(), TextStyle::default());
2273            let node = element.create();
2274
2275            // Initially not focused
2276            assert!(!node.is_focused());
2277
2278            // Set focus
2279            *node.refs.is_focused.borrow_mut() = true;
2280            assert!(node.is_focused());
2281
2282            // Verify the node has correct text
2283            assert_eq!(node.text(), "Test");
2284
2285            // Verify selection is at end
2286            assert_eq!(node.selection().start, 4);
2287        });
2288    }
2289}