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}
47
48/// Shared channel by which a `TextFieldModifierNode` publishes its live handle
49/// [`TextFieldHandleMetrics`] to the `BasicTextField` composable that renders
50/// the handles. Reads subscribe reactively (backed by a revision `MutableState`)
51/// so the composable recomposes when the field's focus/geometry changes.
52#[derive(Clone)]
53pub struct TextFieldHandleController {
54    inner: Rc<TextFieldHandleControllerInner>,
55}
56
57impl PartialEq for TextFieldHandleController {
58    fn eq(&self, other: &Self) -> bool {
59        Rc::ptr_eq(&self.inner, &other.inner)
60    }
61}
62
63struct TextFieldHandleControllerInner {
64    metrics: Cell<Option<TextFieldHandleMetrics>>,
65    revision: MutableState<u64>,
66}
67
68impl TextFieldHandleController {
69    /// Creates a controller. Must run with an active runtime (i.e. inside a
70    /// composition, via `remember`).
71    pub fn new() -> Self {
72        Self {
73            inner: Rc::new(TextFieldHandleControllerInner {
74                metrics: Cell::new(None),
75                revision: mutableStateOf(0u64),
76            }),
77        }
78    }
79
80    /// Publishes fresh metrics, waking any reader only when they actually
81    /// changed (so a resting frame does not spin recomposition).
82    pub(crate) fn publish(&self, metrics: TextFieldHandleMetrics) {
83        if self.inner.metrics.get() != Some(metrics) {
84            self.inner.metrics.set(Some(metrics));
85            self.inner
86                .revision
87                .update(|value| *value = value.wrapping_add(1));
88        }
89    }
90
91    /// Reads the latest metrics, subscribing the current recompose scope to
92    /// future changes.
93    pub fn metrics(&self) -> Option<TextFieldHandleMetrics> {
94        let _ = self.inner.revision.value();
95        self.inner.metrics.get()
96    }
97}
98
99impl Default for TextFieldHandleController {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105/// Default cursor color (white - visible on dark backgrounds)
106const DEFAULT_CURSOR_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0);
107
108/// Default selection highlight color (light blue with transparency)
109const DEFAULT_SELECTION_COLOR: Color = Color(0.0, 0.5, 1.0, 0.3);
110
111/// Default line height for empty text fields
112const DEFAULT_LINE_HEIGHT: f32 = 20.0;
113
114/// Cursor width in pixels
115const CURSOR_WIDTH: f32 = 2.0;
116
117/// Computes the horizontal scroll (pan) offset that keeps the cursor visible
118/// inside the viewport of a single-line text field.
119///
120/// Mirrors Jetpack Compose's `TextFieldScrollerPosition.coerceOffset` behavior:
121/// - the offset only changes when the cursor would leave the viewport,
122/// - the offset is clamped so the text never detaches from the left edge and
123///   never scrolls further than needed to show the end of the text (plus the
124///   cursor width, so a cursor at the end of the text stays visible).
125///
126/// All values are in px within the field's content coordinate space.
127pub(crate) fn compute_horizontal_scroll_offset(
128    current_offset: f32,
129    cursor_x: f32,
130    text_width: f32,
131    viewport_width: f32,
132) -> f32 {
133    if viewport_width <= 0.0 {
134        return 0.0;
135    }
136    let max_offset = (text_width + CURSOR_WIDTH - viewport_width).max(0.0);
137    let mut offset = current_offset.clamp(0.0, max_offset);
138    let visible_end = offset + viewport_width - CURSOR_WIDTH;
139    if cursor_x > visible_end {
140        // Cursor ran past the right edge: pan so it sits at the right edge.
141        offset = cursor_x - viewport_width + CURSOR_WIDTH;
142    } else if cursor_x < offset {
143        // Cursor ran past the left edge: pan so it sits at the left edge.
144        offset = cursor_x;
145    }
146    offset.clamp(0.0, max_offset)
147}
148
149/// Intersects `rect` with `bounds`, returning `None` when nothing remains.
150///
151/// Used to clip selection/cursor/composition primitives to the field's
152/// viewport so they never draw outside the field bounds.
153pub(crate) fn intersect_rect(
154    rect: cranpose_ui_graphics::Rect,
155    bounds: cranpose_ui_graphics::Rect,
156) -> Option<cranpose_ui_graphics::Rect> {
157    let x0 = rect.x.max(bounds.x);
158    let y0 = rect.y.max(bounds.y);
159    let x1 = (rect.x + rect.width).min(bounds.x + bounds.width);
160    let y1 = (rect.y + rect.height).min(bounds.y + bounds.height);
161    (x1 > x0 && y1 > y0).then_some(cranpose_ui_graphics::Rect {
162        x: x0,
163        y: y0,
164        width: x1 - x0,
165        height: y1 - y0,
166    })
167}
168
169/// Resolver that recomputes (and stores) the horizontal pan offset for a
170/// text field given the current content viewport width in px.
171pub type TextPanResolver = Rc<dyn Fn(f32) -> f32>;
172
173/// Shared references for text field input handling.
174///
175/// This struct bundles the shared state references passed to the pointer input handler,
176/// reducing the argument count for `create_handler` from 8 individual `Rc` parameters
177/// to a single struct (fixing clippy::too_many_arguments).
178#[derive(Clone)]
179pub(crate) struct TextFieldRefs {
180    /// Whether this field is currently focused
181    pub is_focused: Rc<RefCell<bool>>,
182    /// Content offset from left (padding) for accurate click positioning
183    pub content_offset: Rc<Cell<f32>>,
184    /// Content offset from top (padding) for cursor Y positioning
185    pub content_y_offset: Rc<Cell<f32>>,
186    /// Drag anchor position (byte offset) for click-drag selection
187    pub drag_anchor: Rc<Cell<Option<usize>>>,
188    /// Last click time for double/triple-click detection
189    pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
190    /// Last click screen position, for multi-tap slop gating
191    pub last_click_pos: Rc<Cell<Option<(f32, f32)>>>,
192    /// Click count (1=single, 2=double, 3=triple)
193    pub click_count: Rc<Cell<u8>>,
194    /// Node ID for scoped layout invalidation
195    pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
196    /// Horizontal scroll (pan) offset in px for single-line fields.
197    /// Keeps the cursor visible when the text is wider than the field.
198    pub scroll_offset: Rc<Cell<f32>>,
199    /// Device source of the most recent pointer press on the field. Drives
200    /// touch-only affordances: finger selection handles are shown for touch /
201    /// stylus presses, while a mouse keeps a clean caret.
202    pub last_pointer_source: Rc<Cell<PointerSource>>,
203    /// Field node's top-left in window coordinates, derived from the most recent
204    /// pointer event (`global_position - position`). Used to place selection
205    /// handles in the top-level overlay, which is in window space.
206    pub node_origin: Rc<Cell<Point>>,
207}
208
209impl TextFieldRefs {
210    /// Creates a new set of shared references.
211    pub fn new() -> Self {
212        Self {
213            is_focused: Rc::new(RefCell::new(false)),
214            content_offset: Rc::new(Cell::new(0.0_f32)),
215            content_y_offset: Rc::new(Cell::new(0.0_f32)),
216            drag_anchor: Rc::new(Cell::new(None::<usize>)),
217            last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
218            last_click_pos: Rc::new(Cell::new(None::<(f32, f32)>)),
219            click_count: Rc::new(Cell::new(0_u8)),
220            node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
221            scroll_offset: Rc::new(Cell::new(0.0_f32)),
222            last_pointer_source: Rc::new(Cell::new(PointerSource::Unknown)),
223            node_origin: Rc::new(Cell::new(Point { x: 0.0, y: 0.0 })),
224        }
225    }
226}
227
228/// Modifier node for editable text fields.
229///
230/// This node is the core of `BasicTextField`, handling:
231/// - Text measurement and layout
232/// - Cursor and selection rendering
233/// - Pointer input for cursor positioning
234use crate::text::TextStyle; // Add import
235
236pub struct TextFieldModifierNode {
237    /// The text field state (shared)
238    state: TextFieldState,
239    /// Shared references for input handling
240    refs: TextFieldRefs,
241    /// Text style
242    style: TextStyle, // Add style
243    /// Cursor brush color
244    cursor_brush: Brush,
245    /// Selection highlight brush
246    selection_brush: Brush,
247    /// Line limits configuration
248    line_limits: TextFieldLineLimits,
249    /// Cached text value for change detection
250    cached_text: String,
251    /// Cached selection for change detection
252    cached_selection: TextRange,
253    /// Node state for delegation
254    node_state: NodeState,
255    /// Measured size cache (shared with the draw closure as the pan viewport)
256    measured_size: Rc<Cell<Size>>,
257    /// Cached line height from last measurement (shared with draw closure)
258    measured_line_height: Rc<Cell<f32>>,
259    /// Cached pointer input handler
260    cached_handler: Rc<dyn Fn(PointerEvent)>,
261    /// Cached horizontal pan resolver (recomputes + stores the scroll offset)
262    cached_pan_resolver: TextPanResolver,
263    /// Channel to publish live handle metrics to the `BasicTextField`
264    /// composable that renders the finger selection handles. `None` when the
265    /// field is used without handle support.
266    handle_controller: Option<TextFieldHandleController>,
267}
268
269impl std::fmt::Debug for TextFieldModifierNode {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        f.debug_struct("TextFieldModifierNode")
272            .field("text", &self.state.text())
273            .field("style", &self.style)
274            .field("is_focused", &*self.refs.is_focused.borrow())
275            .finish()
276    }
277}
278
279// Re-export from extracted module
280use crate::text_field_handler::TextFieldHandler;
281
282impl TextFieldModifierNode {
283    /// Creates a new text field modifier node.
284    pub fn new(state: TextFieldState, style: TextStyle) -> Self {
285        let value = state.value();
286        let refs = TextFieldRefs::new();
287        let line_limits = TextFieldLineLimits::default();
288        let cached_handler =
289            Self::create_handler(state.clone(), refs.clone(), line_limits, style.clone());
290        let cached_pan_resolver =
291            Self::create_pan_resolver(state.clone(), refs.clone(), line_limits, style.clone());
292
293        Self {
294            state,
295            refs,
296            style,
297            cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
298            selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
299            line_limits,
300            cached_text: value.text,
301            cached_selection: value.selection,
302            node_state: NodeState::new(),
303            measured_size: Rc::new(Cell::new(Size {
304                width: 0.0,
305                height: 0.0,
306            })),
307            measured_line_height: Rc::new(Cell::new(DEFAULT_LINE_HEIGHT)),
308            cached_handler,
309            cached_pan_resolver,
310            handle_controller: None,
311        }
312    }
313
314    /// Creates a node with custom line limits.
315    pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
316        self.line_limits = line_limits;
317        self.cached_pan_resolver = Self::create_pan_resolver(
318            self.state.clone(),
319            self.refs.clone(),
320            line_limits,
321            self.style.clone(),
322        );
323        self
324    }
325
326    /// Installs the controller the field publishes live handle metrics to.
327    pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
328        self.handle_controller = Some(controller);
329        self
330    }
331
332    /// Creates the horizontal pan resolver closure.
333    ///
334    /// The resolver takes the content viewport width (px) and returns the
335    /// horizontal scroll offset that keeps the cursor visible, storing the
336    /// result in `refs.scroll_offset` so pointer input and rendering agree.
337    /// It recomputes from the live state so layout, the render scene builder,
338    /// and the draw closure all observe the same value within a frame.
339    fn create_pan_resolver(
340        state: TextFieldState,
341        refs: TextFieldRefs,
342        line_limits: TextFieldLineLimits,
343        style: TextStyle,
344    ) -> TextPanResolver {
345        Rc::new(move |viewport_width: f32| {
346            if !line_limits.is_single_line() {
347                // Multi-line fields do not pan horizontally.
348                refs.scroll_offset.set(0.0);
349                return 0.0;
350            }
351            let text = state.text();
352            let pos = state.selection().start.min(text.len());
353            let text_width = crate::text::measure_text(
354                &crate::text::AnnotatedString::from(text.as_str()),
355                &style,
356            )
357            .width;
358            let cursor_x = crate::text::measure_text(
359                &crate::text::AnnotatedString::from(&text[..pos]),
360                &style,
361            )
362            .width;
363            let offset = compute_horizontal_scroll_offset(
364                refs.scroll_offset.get(),
365                cursor_x,
366                text_width,
367                viewport_width,
368            );
369            refs.scroll_offset.set(offset);
370            offset
371        })
372    }
373
374    /// Returns the pan resolver for single-line fields, `None` for multi-line.
375    ///
376    /// Exposed to the modifier slices so the render scene builder can pan the
377    /// text glyphs by the same offset used for the cursor and selection.
378    pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
379        self.line_limits
380            .is_single_line()
381            .then(|| self.cached_pan_resolver.clone())
382    }
383
384    /// Returns the current horizontal scroll (pan) offset in px.
385    pub fn scroll_offset(&self) -> f32 {
386        self.refs.scroll_offset.get()
387    }
388
389    /// Returns the current line limits configuration.
390    pub fn line_limits(&self) -> TextFieldLineLimits {
391        self.line_limits
392    }
393
394    /// Creates the pointer input handler closure.
395    fn create_handler(
396        state: TextFieldState,
397        refs: TextFieldRefs,
398        line_limits: TextFieldLineLimits,
399        style: TextStyle, // Add style
400    ) -> Rc<dyn Fn(PointerEvent)> {
401        // Word boundaries for double-tap; selection classification/line
402        // boundaries for the tap-count-driven selection gestures.
403        use crate::text_selection::{
404            classify_tap, find_line_boundaries, TapCount, MULTI_TAP_SLOP_PX, MULTI_TAP_TIMEOUT_MS,
405        };
406        use crate::word_boundaries::find_word_boundaries;
407
408        Rc::new(move |event: PointerEvent| {
409            // Seed the field node's window-space origin from this pointer event
410            // so the very first handle placement after a tap has a value even
411            // before the next layout pass runs. The layout pass
412            // (`window_origin_sink`) is the authoritative source that keeps it
413            // fresh as the field scrolls; both agree (`global - local` equals
414            // the composited window origin at rest).
415            refs.node_origin.set(Point {
416                x: event.global_position.x - event.position.x,
417                y: event.global_position.y - event.position.y,
418            });
419
420            // Account for content padding offsets and the horizontal pan
421            // offset (single-line fields pan to keep the cursor visible, so
422            // clicks must map back into text space).
423            let click_x =
424                (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
425            let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
426
427            match event.kind {
428                PointerEventKind::Down => {
429                    // Remember the device that pressed so the draw closure can
430                    // show finger selection handles for touch/stylus and keep a
431                    // clean caret for a mouse.
432                    refs.last_pointer_source.set(event.source);
433
434                    // Request focus with O(1) handler, passing node_id and line_limits for key handling
435                    let handler =
436                        TextFieldHandler::new(state.clone(), refs.node_id.get(), line_limits);
437                    crate::text_field_focus::request_focus(refs.is_focused.clone(), handler);
438
439                    let now = web_time::Instant::now();
440                    let text = state.text();
441                    let pos = crate::text::get_offset_for_position(
442                        &crate::text::AnnotatedString::from(text.as_str()),
443                        &style,
444                        click_x,
445                        click_y,
446                    );
447
448                    // Classify the press into single/double/triple by both the
449                    // time since and the distance from the previous press (a tap
450                    // far from the last one starts a fresh single tap, matching
451                    // Android's double-tap slop).
452                    let previous = refs.click_count.get().try_into().ok().and_then(|count| {
453                        let (px, py) = refs.last_click_pos.get()?;
454                        Some((count, px, py))
455                    });
456                    let elapsed_ms = refs
457                        .last_click_time
458                        .get()
459                        .map(|last| now.duration_since(last).as_millis())
460                        .unwrap_or(u128::MAX);
461                    let tap = classify_tap(
462                        previous,
463                        elapsed_ms,
464                        event.position.x,
465                        event.position.y,
466                        MULTI_TAP_TIMEOUT_MS,
467                        MULTI_TAP_SLOP_PX,
468                    );
469
470                    match tap {
471                        TapCount::Triple => {
472                            // Triple tap/click: select the line/paragraph.
473                            let (line_start, line_end) = find_line_boundaries(&text, pos);
474                            state.edit(|buffer| {
475                                buffer.select(TextRange::new(line_start, line_end));
476                            });
477                            refs.drag_anchor.set(Some(line_start));
478                        }
479                        TapCount::Double => {
480                            // Double tap/click: select the word.
481                            let (word_start, word_end) = find_word_boundaries(&text, pos);
482                            state.edit(|buffer| {
483                                buffer.select(TextRange::new(word_start, word_end));
484                            });
485                            refs.drag_anchor.set(Some(word_start));
486                        }
487                        TapCount::Single => {
488                            // Single tap/click: place the cursor.
489                            refs.drag_anchor.set(Some(pos));
490                            state.edit(|buffer| {
491                                buffer.place_cursor_before_char(pos);
492                            });
493                        }
494                    }
495
496                    refs.click_count.set(tap.as_u8());
497                    refs.last_click_time.set(Some(now));
498                    refs.last_click_pos
499                        .set(Some((event.position.x, event.position.y)));
500                    event.consume();
501                }
502                PointerEventKind::Move => {
503                    // If we have a drag anchor, extend selection during drag
504                    if let Some(anchor) = refs.drag_anchor.get() {
505                        if *refs.is_focused.borrow() {
506                            let text = state.text();
507                            let current_pos = crate::text::get_offset_for_position(
508                                &crate::text::AnnotatedString::from(text.as_str()),
509                                &style,
510                                click_x,
511                                click_y,
512                            );
513
514                            // Update selection directly (without undo stack push)
515                            state.set_selection(TextRange::new(anchor, current_pos));
516
517                            // Selection change only needs redraw, not layout
518                            crate::request_render_invalidation();
519
520                            event.consume();
521                        }
522                    }
523                }
524                PointerEventKind::Up => {
525                    // Clear drag anchor on mouse up
526                    refs.drag_anchor.set(None);
527                }
528                _ => {}
529            }
530        })
531    }
532
533    /// Creates a node with custom cursor color.
534    pub fn with_cursor_color(mut self, color: Color) -> Self {
535        self.cursor_brush = Brush::solid(color);
536        self
537    }
538
539    /// Sets the focus state.
540    pub fn set_focused(&mut self, focused: bool) {
541        let current = *self.refs.is_focused.borrow();
542        if current != focused {
543            *self.refs.is_focused.borrow_mut() = focused;
544        }
545    }
546
547    /// Returns whether the field is focused.
548    pub fn is_focused(&self) -> bool {
549        *self.refs.is_focused.borrow()
550    }
551
552    /// Returns the is_focused Rc for closure capture.
553    pub fn is_focused_rc(&self) -> Rc<RefCell<bool>> {
554        self.refs.is_focused.clone()
555    }
556
557    /// Returns the content_offset Rc for closure capture.
558    pub fn content_offset_rc(&self) -> Rc<Cell<f32>> {
559        self.refs.content_offset.clone()
560    }
561
562    /// Returns the content_y_offset Rc for closure capture.
563    pub fn content_y_offset_rc(&self) -> Rc<Cell<f32>> {
564        self.refs.content_y_offset.clone()
565    }
566
567    /// Returns the shared cell the field's composited window origin is written
568    /// into (window coordinates of the field node's top-left).
569    ///
570    /// The layout pass writes the field's TRUE on-screen origin here every frame
571    /// — resolved through all ancestor placements (a scrolling `LazyColumn` /
572    /// `vertical_scroll` offsets its items via placement, which the layout tree
573    /// bakes into each node's absolute rect) plus ancestor graphics-layer
574    /// translations. The draw closure reads it back to publish handle metrics,
575    /// so the finger selection/cursor handles anchor at (and their window→offset
576    /// inverse mapping agrees with) the field's real glyphs even while the list
577    /// scrolls. Without this the origin was only ever sampled from the last
578    /// pointer event and went stale the moment the field scrolled.
579    pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
580        self.refs.node_origin.clone()
581    }
582
583    /// Returns the current text.
584    pub fn text(&self) -> String {
585        self.state.text()
586    }
587
588    pub fn style(&self) -> &TextStyle {
589        &self.style
590    }
591
592    /// Returns the current selection.
593    pub fn selection(&self) -> TextRange {
594        self.state.selection()
595    }
596
597    /// Returns the cursor brush for rendering.
598    pub fn cursor_brush(&self) -> Brush {
599        self.cursor_brush.clone()
600    }
601
602    /// Returns the selection brush for rendering selection highlight.
603    pub fn selection_brush(&self) -> Brush {
604        self.selection_brush.clone()
605    }
606
607    /// Inserts text at the current cursor position (for paste operations).
608    pub fn insert_text(&mut self, text: &str) {
609        self.state.edit(|buffer| {
610            buffer.insert(text);
611        });
612    }
613
614    /// Copies the selected text and returns it (for web copy operation).
615    /// Returns None if no selection.
616    pub fn copy_selection(&self) -> Option<String> {
617        self.state.copy_selection()
618    }
619
620    /// Cuts the selected text: copies and deletes it.
621    /// Returns the cut text, or None if no selection.
622    pub fn cut_selection(&mut self) -> Option<String> {
623        let text = self.copy_selection();
624        if text.is_some() {
625            self.state.edit(|buffer| {
626                buffer.delete(buffer.selection());
627            });
628        }
629        text
630    }
631
632    /// Returns a clone of the text field state for use in draw closures.
633    /// This allows reading selection at DRAW time rather than LAYOUT time.
634    pub fn get_state(&self) -> cranpose_foundation::text::TextFieldState {
635        self.state.clone()
636    }
637
638    /// Updates the content offset (padding.left) for accurate click-to-position cursor placement.
639    /// Called from slices collection where padding is known.
640    pub fn set_content_offset(&self, offset: f32) {
641        self.refs.content_offset.set(offset);
642    }
643
644    /// Updates the content Y offset (padding.top) for cursor Y positioning.
645    /// Called from slices collection where padding is known.
646    pub fn set_content_y_offset(&self, offset: f32) {
647        self.refs.content_y_offset.set(offset);
648    }
649
650    /// The wrap width a multi-line field lays its text out at, or `None` when
651    /// the text must not wrap (single-line fields pan horizontally instead).
652    ///
653    /// Multi-line fields wrap at the available content width exactly like the
654    /// render scene builder, so the measured height reflects every wrapped line
655    /// and the field grows to fit its content instead of clipping it.
656    fn wrap_width(&self, available_width: f32) -> Option<f32> {
657        (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
658            .then_some(available_width)
659    }
660
661    /// Measures the text content using node-identity-based caching.
662    ///
663    /// `wrap_width` bounds the layout width so multi-line text wraps; `None`
664    /// measures the natural single-line width (single-line fields, intrinsic
665    /// width queries).
666    fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
667        let text = self.state.text();
668        let node_id = self.refs.node_id.get();
669        let annotated = crate::text::AnnotatedString::from(text.as_str());
670        let metrics = match wrap_width {
671            Some(max_width) => crate::text::measure_text_with_options_for_node(
672                node_id,
673                &annotated,
674                &self.style,
675                crate::text::TextLayoutOptions::default(),
676                Some(max_width),
677            ),
678            None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
679        };
680        self.measured_line_height.set(metrics.line_height);
681        Size {
682            width: metrics.width,
683            height: metrics.height,
684        }
685    }
686
687    /// Updates cached state and returns true if changed.
688    fn update_cached_state(&mut self) -> bool {
689        let value = self.state.value();
690        let text_changed = value.text != self.cached_text;
691        let selection_changed = value.selection != self.cached_selection;
692
693        if text_changed {
694            self.cached_text = value.text;
695        }
696        if selection_changed {
697            self.cached_selection = value.selection;
698        }
699
700        text_changed || selection_changed
701    }
702
703    /// Positions cursor at a given x offset within the text.
704    /// Uses proper text layout hit testing for accurate proportional font support.
705    pub fn position_cursor_at_offset(&self, x_offset: f32) {
706        let text = self.state.text();
707        if text.is_empty() {
708            self.state.edit(|buffer| {
709                buffer.place_cursor_at_start();
710            });
711            return;
712        }
713
714        // Use proper text layout hit testing instead of character-based calculation.
715        // Map the viewport-relative offset into text space by adding the pan offset.
716        let byte_offset = crate::text::get_offset_for_position(
717            &crate::text::AnnotatedString::from(text.as_str()),
718            &self.style,
719            x_offset + self.refs.scroll_offset.get(),
720            0.0,
721        );
722
723        self.state.edit(|buffer| {
724            buffer.place_cursor_before_char(byte_offset);
725        });
726    }
727
728    // NOTE: Key event handling is done via TextFieldHandler::handle_key() which is
729    // registered with the focus system for O(1) dispatch. DO NOT add a handle_key_event()
730    // method here - it would be duplicate code that never gets called.
731}
732
733impl DelegatableNode for TextFieldModifierNode {
734    fn node_state(&self) -> &NodeState {
735        &self.node_state
736    }
737}
738
739impl ModifierNode for TextFieldModifierNode {
740    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
741        // Store node_id for scoped layout invalidation (avoids O(app) global invalidation)
742        self.refs.node_id.set(context.node_id());
743
744        context.invalidate(InvalidationKind::Layout);
745        context.invalidate(InvalidationKind::Draw);
746        context.invalidate(InvalidationKind::Semantics);
747    }
748
749    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
750        Some(self)
751    }
752
753    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
754        Some(self)
755    }
756
757    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
758        Some(self)
759    }
760
761    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
762        Some(self)
763    }
764
765    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
766        Some(self)
767    }
768
769    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
770        Some(self)
771    }
772
773    fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
774        Some(self)
775    }
776
777    fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
778        Some(self)
779    }
780}
781
782impl LayoutModifierNode for TextFieldModifierNode {
783    fn measure(
784        &self,
785        _context: &mut dyn ModifierNodeContext,
786        _measurable: &dyn Measurable,
787        constraints: Constraints,
788    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
789        // Measure the text content, wrapping multi-line fields at the available
790        // width so the field grows to fit every wrapped line instead of
791        // clipping content past the first line.
792        let text_size = self.measure_text_content(self.wrap_width(constraints.max_width));
793
794        // Add minimum height for empty text (cursor needs space)
795        let min_height = if text_size.height < 1.0 {
796            DEFAULT_LINE_HEIGHT
797        } else {
798            text_size.height
799        };
800
801        // Constrain to provided constraints
802        let width = text_size
803            .width
804            .max(constraints.min_width)
805            .min(constraints.max_width);
806        let height = min_height
807            .max(constraints.min_height)
808            .min(constraints.max_height);
809
810        let size = Size { width, height };
811        self.measured_size.set(size);
812
813        // Refresh the horizontal pan offset so it is up to date for pointer
814        // input and rendering even before the next draw pass runs.
815        let _ = (self.cached_pan_resolver)(size.width);
816
817        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
818    }
819
820    fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
821        self.measure_text_content(None).width
822    }
823
824    fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
825        self.measure_text_content(None).width
826    }
827
828    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
829        self.measure_text_content(self.wrap_width(width))
830            .height
831            .max(DEFAULT_LINE_HEIGHT)
832    }
833
834    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
835        self.measure_text_content(self.wrap_width(width))
836            .height
837            .max(DEFAULT_LINE_HEIGHT)
838    }
839}
840
841impl DrawModifierNode for TextFieldModifierNode {
842    fn draw(&self, _draw_scope: &mut dyn DrawScope) {
843        // No-op: Cursor and selection are rendered via create_draw_closure() which
844        // creates DrawPrimitive::Rect directly. This enables draw-time evaluation
845        // of focus state and cursor blink timing.
846    }
847
848    fn create_draw_closure(
849        &self,
850    ) -> Option<Rc<dyn Fn(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
851    {
852        use cranpose_ui_graphics::DrawPrimitive;
853
854        // Capture state via Rc clone (cheap) for draw-time evaluation
855        let is_focused = self.refs.is_focused.clone();
856        let state = self.state.clone();
857        let content_offset = self.refs.content_offset.clone();
858        let content_y_offset = self.refs.content_y_offset.clone();
859        let cursor_brush = self.cursor_brush.clone();
860        let selection_brush = self.selection_brush.clone();
861        let style = self.style.clone();
862        let cached_line_height = self.measured_line_height.clone();
863        let measured_size = self.measured_size.clone();
864        let pan_resolver = self.cached_pan_resolver.clone();
865        let handle_controller = self.handle_controller.clone();
866        let node_origin = self.refs.node_origin.clone();
867        let last_pointer_source = self.refs.last_pointer_source.clone();
868
869        Some(Rc::new(move |size| {
870            // Check focus at DRAW time
871            if !*is_focused.borrow() {
872                // Publish an unfocused snapshot so the composable clears any
873                // finger handles when the field loses focus.
874                if let Some(controller) = &handle_controller {
875                    controller.publish(TextFieldHandleMetrics {
876                        focused: false,
877                        touch: false,
878                        node_origin: node_origin.get(),
879                        padding_left: 0.0,
880                        padding_top: 0.0,
881                        scroll_offset: 0.0,
882                        line_height: cached_line_height.get(),
883                    });
884                }
885                return vec![];
886            }
887
888            let mut primitives = Vec::new();
889
890            let text = state.text();
891            let selection = state.selection();
892            let padding_left = content_offset.get();
893            let padding_top = content_y_offset.get();
894            // Reuse line_height from the most recent layout measurement
895            // instead of re-measuring the full text.
896            let line_height = cached_line_height.get();
897
898            // Content viewport (excludes padding). Fall back to the node size
899            // when measurement has not run yet.
900            let measured = measured_size.get();
901            let viewport_width = if measured.width > 0.0 {
902                measured.width
903            } else {
904                (size.width - padding_left).max(0.0)
905            };
906            let viewport_height = if measured.height > 0.0 {
907                measured.height
908            } else {
909                (size.height - padding_top).max(0.0)
910            };
911            // Horizontal pan that keeps the cursor visible (0 for multi-line).
912            let pan = pan_resolver(viewport_width);
913
914            // Publish live geometry so the `BasicTextField` composable can place
915            // and drive the finger selection handles.
916            if let Some(controller) = &handle_controller {
917                controller.publish(TextFieldHandleMetrics {
918                    focused: true,
919                    touch: last_pointer_source.get().is_touch_like(),
920                    node_origin: node_origin.get(),
921                    padding_left,
922                    padding_top,
923                    scroll_offset: pan,
924                    line_height,
925                });
926            }
927            // Everything the field draws (selection, IME underline, cursor)
928            // is clipped to the content viewport so primitives never extend
929            // outside the field bounds.
930            let clip_bounds = cranpose_ui_graphics::Rect {
931                x: padding_left,
932                y: padding_top,
933                width: viewport_width,
934                height: viewport_height,
935            };
936
937            // Draw selection highlight
938            if !selection.collapsed() {
939                let sel_start = selection.min();
940                let sel_end = selection.max();
941
942                let lines: Vec<&str> = text.split('\n').collect();
943                let mut byte_offset: usize = 0;
944
945                for (line_idx, line) in lines.iter().enumerate() {
946                    let line_start = byte_offset;
947                    let line_end = byte_offset + line.len();
948
949                    if sel_end > line_start && sel_start < line_end {
950                        let sel_start_in_line = sel_start.saturating_sub(line_start);
951                        let sel_end_in_line = (sel_end - line_start).min(line.len());
952
953                        let sel_start_x = crate::text::measure_text(
954                            &crate::text::AnnotatedString::from(&line[..sel_start_in_line]),
955                            &style,
956                        )
957                        .width
958                            + padding_left
959                            - pan;
960                        let sel_end_x = crate::text::measure_text(
961                            &crate::text::AnnotatedString::from(&line[..sel_end_in_line]),
962                            &style,
963                        )
964                        .width
965                            + padding_left
966                            - pan;
967                        let sel_width = sel_end_x - sel_start_x;
968
969                        if sel_width > 0.0 {
970                            let sel_rect = cranpose_ui_graphics::Rect {
971                                x: sel_start_x,
972                                y: padding_top + line_idx as f32 * line_height,
973                                width: sel_width,
974                                height: line_height,
975                            };
976                            if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
977                                primitives.push(DrawPrimitive::Rect {
978                                    rect: clipped,
979                                    brush: selection_brush.clone(),
980                                });
981                            }
982                        }
983                    }
984                    byte_offset = line_end + 1;
985                }
986            }
987
988            // Draw composition (IME preedit) underline
989            // This shows the user which text is being composed by the input method
990            if let Some(comp_range) = state.composition() {
991                let comp_start = comp_range.min();
992                let comp_end = comp_range.max();
993
994                if comp_start < comp_end && comp_end <= text.len() {
995                    let lines: Vec<&str> = text.split('\n').collect();
996                    let mut byte_offset: usize = 0;
997
998                    // Underline color: slightly transparent white/gray
999                    let underline_brush = cranpose_ui_graphics::Brush::solid(
1000                        cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
1001                    );
1002                    let underline_height: f32 = 2.0;
1003
1004                    for (line_idx, line) in lines.iter().enumerate() {
1005                        let line_start = byte_offset;
1006                        let line_end = byte_offset + line.len();
1007
1008                        // Check if composition overlaps this line
1009                        if comp_end > line_start && comp_start < line_end {
1010                            let comp_start_in_line = comp_start.saturating_sub(line_start);
1011                            let comp_end_in_line = (comp_end - line_start).min(line.len());
1012
1013                            // Clamp to valid UTF-8 boundaries
1014                            let comp_start_in_line = if line.is_char_boundary(comp_start_in_line) {
1015                                comp_start_in_line
1016                            } else {
1017                                0
1018                            };
1019                            let comp_end_in_line = if line.is_char_boundary(comp_end_in_line) {
1020                                comp_end_in_line
1021                            } else {
1022                                line.len()
1023                            };
1024
1025                            let comp_start_x = crate::text::measure_text(
1026                                &crate::text::AnnotatedString::from(&line[..comp_start_in_line]),
1027                                &style,
1028                            )
1029                            .width
1030                                + padding_left
1031                                - pan;
1032                            let comp_end_x = crate::text::measure_text(
1033                                &crate::text::AnnotatedString::from(&line[..comp_end_in_line]),
1034                                &style,
1035                            )
1036                            .width
1037                                + padding_left
1038                                - pan;
1039                            let comp_width = comp_end_x - comp_start_x;
1040
1041                            if comp_width > 0.0 {
1042                                // Draw underline at the bottom of the text line
1043                                let underline_rect = cranpose_ui_graphics::Rect {
1044                                    x: comp_start_x,
1045                                    y: padding_top + (line_idx as f32 + 1.0) * line_height
1046                                        - underline_height,
1047                                    width: comp_width,
1048                                    height: underline_height,
1049                                };
1050                                if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1051                                    primitives.push(DrawPrimitive::Rect {
1052                                        rect: clipped,
1053                                        brush: underline_brush.clone(),
1054                                    });
1055                                }
1056                            }
1057                        }
1058                        byte_offset = line_end + 1;
1059                    }
1060                }
1061            }
1062
1063            // Draw cursor - check visibility at DRAW time for blinking
1064            if crate::cursor_animation::is_cursor_visible() {
1065                let pos = selection.start.min(text.len());
1066                let text_before = &text[..pos];
1067                let line_index = text_before.matches('\n').count();
1068                let line_start = text_before.rfind('\n').map(|i| i + 1).unwrap_or(0);
1069                let cursor_x = crate::text::measure_text(
1070                    &crate::text::AnnotatedString::from(&text_before[line_start..]),
1071                    &style,
1072                )
1073                .width
1074                    + padding_left
1075                    - pan;
1076                let cursor_y = padding_top + line_index as f32 * line_height;
1077
1078                let cursor_rect = cranpose_ui_graphics::Rect {
1079                    x: cursor_x,
1080                    y: cursor_y,
1081                    width: CURSOR_WIDTH,
1082                    height: line_height,
1083                };
1084
1085                if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1086                    primitives.push(DrawPrimitive::Rect {
1087                        rect: clipped,
1088                        brush: cursor_brush.clone(),
1089                    });
1090                }
1091            }
1092
1093            primitives
1094        }))
1095    }
1096}
1097
1098impl SemanticsNode for TextFieldModifierNode {
1099    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1100        let text = self.state.text();
1101        config.content_description = Some(text);
1102        config.is_editable_text = true;
1103        config.text_selection = Some(self.state.selection());
1104    }
1105}
1106
1107impl PointerInputNode for TextFieldModifierNode {
1108    fn on_pointer_event(
1109        &mut self,
1110        _context: &mut dyn ModifierNodeContext,
1111        _event: &PointerEvent,
1112    ) -> bool {
1113        // No-op: All pointer handling is done via pointer_input_handler() closure.
1114        // This follows Jetpack Compose's delegation pattern where the node simply
1115        // forwards to a delegated pointer input handler (see TextFieldDecoratorModifier.kt:741-747).
1116        //
1117        // The cached_handler closure handles:
1118        // - Focus request on Down
1119        // - Cursor positioning
1120        // - Double-click word selection
1121        // - Triple-click select all
1122        // - Drag selection
1123        false
1124    }
1125
1126    fn hit_test(&self, x: f32, y: f32) -> bool {
1127        // Check if point is within measured bounds
1128        let size = self.measured_size.get();
1129        x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1130    }
1131
1132    fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1133        // Return cached handler for pointer input dispatch
1134        Some(self.cached_handler.clone())
1135    }
1136}
1137
1138// ============================================================================
1139// TextFieldElement - Creates and updates TextFieldModifierNode
1140// ============================================================================
1141
1142/// Element that creates and updates `TextFieldModifierNode` instances.
1143///
1144/// This follows the modifier element pattern where the element is responsible for:
1145/// - Creating new nodes (via `create`)
1146/// - Updating existing nodes when properties change (via `update`)
1147/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
1148#[derive(Clone)]
1149pub struct TextFieldElement {
1150    /// The text field state
1151    state: TextFieldState,
1152    /// Text style
1153    style: TextStyle,
1154    /// Cursor color
1155    cursor_color: Color,
1156    /// Line limits configuration
1157    line_limits: TextFieldLineLimits,
1158    /// Channel the node publishes live handle metrics to (finger selection
1159    /// handles). `None` disables handle support.
1160    handle_controller: Option<TextFieldHandleController>,
1161}
1162
1163impl TextFieldElement {
1164    /// Creates a new text field element.
1165    pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1166        Self {
1167            state,
1168            style,
1169            cursor_color: DEFAULT_CURSOR_COLOR,
1170            line_limits: TextFieldLineLimits::default(),
1171            handle_controller: None,
1172        }
1173    }
1174
1175    /// Creates an element with custom cursor color.
1176    pub fn with_cursor_color(mut self, color: Color) -> Self {
1177        self.cursor_color = color;
1178        self
1179    }
1180
1181    /// Creates an element with custom line limits.
1182    pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1183        self.line_limits = line_limits;
1184        self
1185    }
1186
1187    /// Installs the finger-handle metrics channel shared with the composable.
1188    pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1189        self.handle_controller = Some(controller);
1190        self
1191    }
1192}
1193
1194impl std::fmt::Debug for TextFieldElement {
1195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1196        f.debug_struct("TextFieldElement")
1197            .field("text", &self.state.text())
1198            .field("style", &self.style)
1199            .field("cursor_color", &self.cursor_color)
1200            .finish()
1201    }
1202}
1203
1204impl Hash for TextFieldElement {
1205    fn hash<H: Hasher>(&self, state: &mut H) {
1206        // Hash by state Rc pointer identity - matches PartialEq
1207        // This ensures equal elements hash equal (correctness requirement)
1208        std::ptr::hash(std::rc::Rc::as_ptr(&self.state.inner), state);
1209        // Hash cursor color
1210        self.cursor_color.0.to_bits().hash(state);
1211        self.cursor_color.1.to_bits().hash(state);
1212        self.cursor_color.2.to_bits().hash(state);
1213        self.cursor_color.3.to_bits().hash(state);
1214        self.style.render_hash().hash(state);
1215        self.line_limits.hash(state);
1216    }
1217}
1218
1219impl PartialEq for TextFieldElement {
1220    fn eq(&self, other: &Self) -> bool {
1221        // Compare by state identity (same Rc), cursor color, and line limits
1222        // This ensures node reuse when same state is passed, while detecting
1223        // actual changes that require updates
1224        self.state == other.state
1225            && self.style == other.style
1226            && self.cursor_color == other.cursor_color
1227            && self.line_limits == other.line_limits
1228    }
1229}
1230
1231impl Eq for TextFieldElement {}
1232
1233impl ModifierNodeElement for TextFieldElement {
1234    type Node = TextFieldModifierNode;
1235
1236    fn create(&self) -> Self::Node {
1237        let mut node = TextFieldModifierNode::new(self.state.clone(), self.style.clone())
1238            .with_cursor_color(self.cursor_color)
1239            .with_line_limits(self.line_limits);
1240        if let Some(controller) = self.handle_controller.clone() {
1241            node = node.with_handle_controller(controller);
1242        }
1243        node
1244    }
1245
1246    fn update(&self, node: &mut Self::Node) {
1247        // Update the state reference
1248        node.state = self.state.clone();
1249        node.style = self.style.clone();
1250        node.cursor_brush = Brush::solid(self.cursor_color);
1251        node.line_limits = self.line_limits;
1252        node.handle_controller = self.handle_controller.clone();
1253
1254        // Recreate the cached handler with the new state but same refs
1255        node.cached_handler = TextFieldModifierNode::create_handler(
1256            node.state.clone(),
1257            node.refs.clone(),
1258            node.line_limits,
1259            self.style.clone(),
1260        );
1261
1262        // Recreate the pan resolver so it captures the new state/style/limits
1263        node.cached_pan_resolver = TextFieldModifierNode::create_pan_resolver(
1264            node.state.clone(),
1265            node.refs.clone(),
1266            node.line_limits,
1267            self.style.clone(),
1268        );
1269
1270        // Check if content changed and update cache
1271        if node.update_cached_state() {
1272            // Content changed - node will need layout/draw invalidation
1273            // This happens automatically through the modifier reconciliation
1274        }
1275    }
1276
1277    fn capabilities(&self) -> NodeCapabilities {
1278        NodeCapabilities::LAYOUT
1279            | NodeCapabilities::DRAW
1280            | NodeCapabilities::SEMANTICS
1281            | NodeCapabilities::POINTER_INPUT
1282    }
1283
1284    fn always_update(&self) -> bool {
1285        // Always update to capture new state/handler while preserving focus state
1286        true
1287    }
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292    use super::*;
1293    use crate::text::TextStyle;
1294    use cranpose_core::{DefaultScheduler, Runtime};
1295    use std::sync::Arc;
1296
1297    /// Sets up a test runtime and keeps it alive for the duration of the test.
1298    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1299        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1300        f()
1301    }
1302
1303    #[test]
1304    fn text_field_node_creation() {
1305        let _app_context = crate::render_state::app_context_test_scope();
1306        with_test_runtime(|| {
1307            let state = TextFieldState::new("Hello");
1308            let node = TextFieldModifierNode::new(state, TextStyle::default());
1309            assert_eq!(node.text(), "Hello");
1310            assert!(!node.is_focused());
1311        });
1312    }
1313
1314    #[test]
1315    fn text_field_node_focus() {
1316        let _app_context = crate::render_state::app_context_test_scope();
1317        with_test_runtime(|| {
1318            let state = TextFieldState::new("Test");
1319            let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1320            assert!(!node.is_focused());
1321
1322            node.set_focused(true);
1323            assert!(node.is_focused());
1324
1325            node.set_focused(false);
1326            assert!(!node.is_focused());
1327        });
1328    }
1329
1330    #[test]
1331    fn text_field_element_creates_node() {
1332        let _app_context = crate::render_state::app_context_test_scope();
1333        with_test_runtime(|| {
1334            let state = TextFieldState::new("Hello World");
1335            let element = TextFieldElement::new(state, TextStyle::default());
1336
1337            let node = element.create();
1338            assert_eq!(node.text(), "Hello World");
1339        });
1340    }
1341
1342    /// End-to-end guard for the touch-vs-mouse finger-handle pipeline through
1343    /// the real pointer handler and draw closure: a Touch-source press makes the
1344    /// focused field publish `touch = true` (so `SelectionHandles` shows the
1345    /// finger cursor/selection handles and the Copy/Cut/Paste popup), while a
1346    /// Mouse-source press publishes `touch = false` (a clean caret, no handles).
1347    /// The published `touch` flag is exactly what the overlay is gated on, so
1348    /// this pins the source → `last_pointer_source` → metrics link the Android
1349    /// touch handles depend on.
1350    #[test]
1351    fn touch_press_publishes_touch_handle_metrics_but_mouse_does_not() {
1352        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1353        use cranpose_ui_graphics::Point;
1354
1355        let _app_context = crate::render_state::app_context_test_scope();
1356        with_test_runtime(|| {
1357            let state = TextFieldState::new("hello world");
1358            let controller = TextFieldHandleController::new();
1359            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1360                .with_handle_controller(controller.clone());
1361            // Give the field a measured size so the draw closure has geometry.
1362            node.measured_size.set(Size {
1363                width: 120.0,
1364                height: 20.0,
1365            });
1366
1367            let handler = node
1368                .pointer_input_handler()
1369                .expect("field exposes a pointer handler");
1370            let draw = node
1371                .create_draw_closure()
1372                .expect("field exposes a draw closure");
1373            let at = Point { x: 12.0, y: 8.0 };
1374            let size = Size {
1375                width: 120.0,
1376                height: 20.0,
1377            };
1378
1379            // Touch tap: the field focuses and remembers the touch source, so
1380            // its draw closure publishes touch = true.
1381            handler(
1382                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1383            );
1384            let _ = draw(size);
1385            let metrics = controller
1386                .metrics()
1387                .expect("focused field publishes handle metrics");
1388            assert!(metrics.focused, "a tap focuses the field");
1389            assert!(
1390                metrics.touch,
1391                "a touch tap must publish touch = true so the finger handles show"
1392            );
1393
1394            // Mouse tap on the same field: the source flips to mouse, so the
1395            // field publishes touch = false (clean caret, no finger handles).
1396            handler(
1397                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1398            );
1399            let _ = draw(size);
1400            let metrics = controller
1401                .metrics()
1402                .expect("focused field publishes handle metrics");
1403            assert!(
1404                !metrics.touch,
1405                "a mouse tap must publish touch = false (clean caret, no finger handle)"
1406            );
1407
1408            crate::text_field_focus::clear_focus();
1409        });
1410    }
1411
1412    /// A double tap on a word must select that word. This regressed after
1413    /// selection handles began appearing inside `LazyColumn` items in 0.1.39:
1414    /// the cursor handle shown by the first tap overlapped the text line and
1415    /// consumed the second tap. The field's own gesture classification (proven
1416    /// here) is correct — two quick taps at the same spot escalate to a word
1417    /// selection — so the fix is geometric (keep the handle's touch box off the
1418    /// text line; see `selection_handle::handle_shape`).
1419    #[test]
1420    fn double_tap_selects_the_word_under_the_finger() {
1421        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1422        use cranpose_ui_graphics::Point;
1423
1424        let _app_context = crate::render_state::app_context_test_scope();
1425        with_test_runtime(|| {
1426            let state = TextFieldState::new("hello world");
1427            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1428            node.measured_size.set(Size {
1429                width: 200.0,
1430                height: 20.0,
1431            });
1432            let handler = node
1433                .pointer_input_handler()
1434                .expect("field exposes a pointer handler");
1435
1436            // Two touch taps at the same spot, back to back (well within the
1437            // multi-tap timeout and slop): near the start of "hello".
1438            let at = Point { x: 2.0, y: 8.0 };
1439            handler(
1440                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1441            );
1442            handler(
1443                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1444            );
1445
1446            let selection = state.selection();
1447            assert!(
1448                !selection.collapsed(),
1449                "a double tap must produce a (word) selection, got {selection:?}"
1450            );
1451            let selected = &state.text()[selection.min()..selection.max()];
1452            assert_eq!(
1453                selected, "hello",
1454                "double tap should select the whole word under the finger"
1455            );
1456
1457            crate::text_field_focus::clear_focus();
1458        });
1459    }
1460
1461    #[test]
1462    fn text_field_element_equality() {
1463        let _app_context = crate::render_state::app_context_test_scope();
1464        with_test_runtime(|| {
1465            let state1 = TextFieldState::new("Hello");
1466            let state2 = TextFieldState::new("Hello"); // Different Rc, same text
1467
1468            let elem1 = TextFieldElement::new(state1.clone(), TextStyle::default());
1469            let elem2 = TextFieldElement::new(state1.clone(), TextStyle::default()); // Same state (Rc identity)
1470            let elem3 = TextFieldElement::new(state2, TextStyle::default()); // Different state
1471
1472            // Elements are equal only when they share the same state Rc
1473            // This ensures proper Eq/Hash contract compliance
1474            assert_eq!(elem1, elem2, "Same state should be equal");
1475            assert_ne!(elem1, elem3, "Different states should not be equal");
1476        });
1477    }
1478
1479    #[test]
1480    fn text_field_element_update_refreshes_existing_node_style() {
1481        let _app_context = crate::render_state::app_context_test_scope();
1482        with_test_runtime(|| {
1483            let state = TextFieldState::new("themed text");
1484            let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
1485                color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
1486                ..crate::text::SpanStyle::default()
1487            });
1488            let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
1489                color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
1490                ..crate::text::SpanStyle::default()
1491            });
1492            let initial = TextFieldElement::new(state.clone(), dark_style);
1493            let updated = TextFieldElement::new(state, light_style.clone());
1494            let mut node = initial.create();
1495
1496            updated.update(&mut node);
1497
1498            assert_eq!(node.text(), "themed text");
1499            assert_eq!(node.style(), &light_style);
1500        });
1501    }
1502
1503    /// A multi-line field must measure the *wrapped* height at the available
1504    /// width, so a long transcript grows the field instead of being clipped to
1505    /// a single line. Regression for the "edits only appear after focus loss"
1506    /// bug where a wrapped OCR transcript rendered only its first line.
1507    #[test]
1508    fn multiline_field_measures_wrapped_height() {
1509        let _app_context = crate::render_state::app_context_test_scope();
1510        with_test_runtime(|| {
1511            let long = "abcd ".repeat(40); // ~200 chars, no explicit newlines
1512            let state = TextFieldState::new(&long);
1513            let node = TextFieldModifierNode::new(state, TextStyle::default());
1514            assert!(
1515                !node.line_limits().is_single_line(),
1516                "default fields are multi-line"
1517            );
1518
1519            let natural = node.measure_text_content(None);
1520            let wrapped = node.measure_text_content(node.wrap_width(20.0));
1521
1522            assert!(
1523                wrapped.height > natural.height,
1524                "wrapped multi-line height {} must exceed the single-line height {}",
1525                wrapped.height,
1526                natural.height
1527            );
1528        });
1529    }
1530
1531    /// Single-line fields pan horizontally instead of wrapping, so they never
1532    /// derive a wrap width even under a narrow constraint.
1533    #[test]
1534    fn single_line_field_never_wraps() {
1535        let _app_context = crate::render_state::app_context_test_scope();
1536        with_test_runtime(|| {
1537            let state = TextFieldState::new("abcd ".repeat(40));
1538            let node = TextFieldModifierNode::new(state, TextStyle::default())
1539                .with_line_limits(TextFieldLineLimits::SingleLine);
1540            assert_eq!(
1541                node.wrap_width(20.0),
1542                None,
1543                "single-line fields must not wrap"
1544            );
1545        });
1546    }
1547
1548    /// Test that cursor draw command position is calculated correctly.
1549    ///
1550    /// This test verifies that when we measure text width for cursor position:
1551    /// 1. The cursor x position = width of text before cursor
1552    /// 2. For text at cursor end, x = full text width
1553    #[test]
1554    fn test_cursor_x_position_calculation() {
1555        let _app_context = crate::render_state::app_context_test_scope();
1556        with_test_runtime(|| {
1557            // Test that text measurement works correctly for cursor positioning
1558            let style = crate::text::TextStyle::default();
1559
1560            // Empty text - cursor should be at x=0
1561            let empty_width =
1562                crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
1563            assert!(
1564                empty_width.abs() < 0.1,
1565                "Empty text should have 0 width, got {}",
1566                empty_width
1567            );
1568
1569            // Non-empty text - cursor at end should be at text width
1570            let hi_width =
1571                crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
1572            assert!(
1573                hi_width > 0.0,
1574                "Text 'Hi' should have positive width: {}",
1575                hi_width
1576            );
1577
1578            // Partial text - cursor after 'H' should be at width of 'H'
1579            let h_width =
1580                crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
1581            assert!(h_width > 0.0, "Text 'H' should have positive width");
1582            assert!(
1583                h_width < hi_width,
1584                "'H' width {} should be less than 'Hi' width {}",
1585                h_width,
1586                hi_width
1587            );
1588
1589            // Verify TextFieldState selection tracks cursor correctly
1590            let state = TextFieldState::new("Hi");
1591            assert_eq!(
1592                state.selection().start,
1593                2,
1594                "Cursor should be at position 2 (end of 'Hi')"
1595            );
1596
1597            // The text before cursor at position 2 in "Hi" is "Hi" itself
1598            let text = state.text();
1599            let cursor_pos = state.selection().start;
1600            let text_before_cursor = &text[..cursor_pos.min(text.len())];
1601            assert_eq!(text_before_cursor, "Hi");
1602
1603            // So cursor x = width of "Hi"
1604            let cursor_x = crate::text::measure_text(
1605                &crate::text::AnnotatedString::from(text_before_cursor),
1606                &style,
1607            )
1608            .width;
1609            assert!(
1610                (cursor_x - hi_width).abs() < 0.1,
1611                "Cursor x {} should equal 'Hi' width {}",
1612                cursor_x,
1613                hi_width
1614            );
1615        });
1616    }
1617
1618    /// Test cursor is created when focused node is in slices.
1619    #[test]
1620    fn test_focused_node_creates_cursor() {
1621        let _app_context = crate::render_state::app_context_test_scope();
1622        with_test_runtime(|| {
1623            let state = TextFieldState::new("Test");
1624            let element = TextFieldElement::new(state.clone(), TextStyle::default());
1625            let node = element.create();
1626
1627            // Initially not focused
1628            assert!(!node.is_focused());
1629
1630            // Set focus
1631            *node.refs.is_focused.borrow_mut() = true;
1632            assert!(node.is_focused());
1633
1634            // Verify the node has correct text
1635            assert_eq!(node.text(), "Test");
1636
1637            // Verify selection is at end
1638            assert_eq!(node.selection().start, 4);
1639        });
1640    }
1641}