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