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        // Tap-count classification plus word/line/paragraph boundaries drive the
402        // multi-tap selection granularity gestures.
403        use crate::text_selection::{
404            classify_tap_count, find_line_boundaries, find_paragraph_boundaries,
405            tap_selection_granularity, SelectionGranularity, MULTI_TAP_SLOP_PX,
406            MULTI_TAP_TIMEOUT_MS,
407        };
408        use crate::word_boundaries::find_word_boundaries;
409
410        Rc::new(move |event: PointerEvent| {
411            // Seed the field node's window-space origin from this pointer event
412            // so the very first handle placement after a tap has a value even
413            // before the next layout pass runs. The layout pass
414            // (`window_origin_sink`) is the authoritative source that keeps it
415            // fresh as the field scrolls; both agree (`global - local` equals
416            // the composited window origin at rest).
417            refs.node_origin.set(Point {
418                x: event.global_position.x - event.position.x,
419                y: event.global_position.y - event.position.y,
420            });
421
422            // Account for content padding offsets and the horizontal pan
423            // offset (single-line fields pan to keep the cursor visible, so
424            // clicks must map back into text space).
425            let click_x =
426                (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
427            let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
428
429            match event.kind {
430                PointerEventKind::Down => {
431                    // Remember the device that pressed so the draw closure can
432                    // show finger selection handles for touch/stylus and keep a
433                    // clean caret for a mouse.
434                    refs.last_pointer_source.set(event.source);
435
436                    // Request focus with O(1) handler, passing node_id and line_limits for key handling
437                    let handler =
438                        TextFieldHandler::new(state.clone(), refs.node_id.get(), line_limits);
439                    crate::text_field_focus::request_focus(refs.is_focused.clone(), handler);
440
441                    let now = web_time::Instant::now();
442                    let text = state.text();
443                    let pos = crate::text::get_offset_for_position(
444                        &crate::text::AnnotatedString::from(text.as_str()),
445                        &style,
446                        click_x,
447                        click_y,
448                    );
449
450                    // Classify the press into a 1-based tap count by both the
451                    // time since and the distance from the previous press (a tap
452                    // far from the last one starts a fresh single tap, matching
453                    // Android's double-tap slop).
454                    let previous = refs.last_click_pos.get().and_then(|(px, py)| {
455                        let count = refs.click_count.get();
456                        (count > 0).then_some((count, px, py))
457                    });
458                    let elapsed_ms = refs
459                        .last_click_time
460                        .get()
461                        .map(|last| now.duration_since(last).as_millis())
462                        .unwrap_or(u128::MAX);
463                    let tap_count = classify_tap_count(
464                        previous,
465                        elapsed_ms,
466                        event.position.x,
467                        event.position.y,
468                        MULTI_TAP_TIMEOUT_MS,
469                        MULTI_TAP_SLOP_PX,
470                    );
471
472                    // A lone tap that lands INSIDE an existing (non-collapsed)
473                    // selection selects the word under the finger (Android/iOS
474                    // "tap the selection to re-grab a word"), and seeds the
475                    // progression at "word" so the next in-place tap escalates to
476                    // line then paragraph. A lone tap elsewhere just places the
477                    // caret.
478                    let selection = state.selection();
479                    let effective_count = if tap_count == 1
480                        && !selection.collapsed()
481                        && pos >= selection.min()
482                        && pos <= selection.max()
483                    {
484                        2
485                    } else {
486                        tap_count
487                    };
488
489                    match tap_selection_granularity(effective_count) {
490                        SelectionGranularity::Paragraph => {
491                            // Fourth tap: grow to the whole paragraph.
492                            let (start, end) = find_paragraph_boundaries(&text, pos);
493                            state.edit(|buffer| {
494                                buffer.select(TextRange::new(start, end));
495                            });
496                            refs.drag_anchor.set(Some(start));
497                        }
498                        SelectionGranularity::Line => {
499                            // Triple tap: select the line.
500                            let (line_start, line_end) = find_line_boundaries(&text, pos);
501                            state.edit(|buffer| {
502                                buffer.select(TextRange::new(line_start, line_end));
503                            });
504                            refs.drag_anchor.set(Some(line_start));
505                        }
506                        SelectionGranularity::Word => {
507                            // Double tap (or a tap inside an existing selection):
508                            // select the word.
509                            let (word_start, word_end) = find_word_boundaries(&text, pos);
510                            state.edit(|buffer| {
511                                buffer.select(TextRange::new(word_start, word_end));
512                            });
513                            refs.drag_anchor.set(Some(word_start));
514                        }
515                        SelectionGranularity::Caret => {
516                            // Single tap: place the cursor.
517                            refs.drag_anchor.set(Some(pos));
518                            state.edit(|buffer| {
519                                buffer.place_cursor_before_char(pos);
520                            });
521                        }
522                    }
523
524                    refs.click_count.set(effective_count);
525                    refs.last_click_time.set(Some(now));
526                    refs.last_click_pos
527                        .set(Some((event.position.x, event.position.y)));
528                    event.consume();
529                }
530                PointerEventKind::Move => {
531                    // If we have a drag anchor, extend selection during drag
532                    if let Some(anchor) = refs.drag_anchor.get() {
533                        if *refs.is_focused.borrow() {
534                            let text = state.text();
535                            let current_pos = crate::text::get_offset_for_position(
536                                &crate::text::AnnotatedString::from(text.as_str()),
537                                &style,
538                                click_x,
539                                click_y,
540                            );
541
542                            // Update selection directly (without undo stack push)
543                            state.set_selection(TextRange::new(anchor, current_pos));
544
545                            // Selection change only needs redraw, not layout
546                            crate::request_render_invalidation();
547
548                            event.consume();
549                        }
550                    }
551                }
552                PointerEventKind::Up => {
553                    // Clear drag anchor on mouse up
554                    refs.drag_anchor.set(None);
555                }
556                _ => {}
557            }
558        })
559    }
560
561    /// Creates a node with custom cursor color.
562    pub fn with_cursor_color(mut self, color: Color) -> Self {
563        self.cursor_brush = Brush::solid(color);
564        self
565    }
566
567    /// Sets the focus state.
568    pub fn set_focused(&mut self, focused: bool) {
569        let current = *self.refs.is_focused.borrow();
570        if current != focused {
571            *self.refs.is_focused.borrow_mut() = focused;
572        }
573    }
574
575    /// Returns whether the field is focused.
576    pub fn is_focused(&self) -> bool {
577        *self.refs.is_focused.borrow()
578    }
579
580    /// Returns the is_focused Rc for closure capture.
581    pub fn is_focused_rc(&self) -> Rc<RefCell<bool>> {
582        self.refs.is_focused.clone()
583    }
584
585    /// Returns the content_offset Rc for closure capture.
586    pub fn content_offset_rc(&self) -> Rc<Cell<f32>> {
587        self.refs.content_offset.clone()
588    }
589
590    /// Returns the content_y_offset Rc for closure capture.
591    pub fn content_y_offset_rc(&self) -> Rc<Cell<f32>> {
592        self.refs.content_y_offset.clone()
593    }
594
595    /// Returns the shared cell the field's composited window origin is written
596    /// into (window coordinates of the field node's top-left).
597    ///
598    /// The layout pass writes the field's TRUE on-screen origin here every frame
599    /// — resolved through all ancestor placements (a scrolling `LazyColumn` /
600    /// `vertical_scroll` offsets its items via placement, which the layout tree
601    /// bakes into each node's absolute rect) plus ancestor graphics-layer
602    /// translations. The draw closure reads it back to publish handle metrics,
603    /// so the finger selection/cursor handles anchor at (and their window→offset
604    /// inverse mapping agrees with) the field's real glyphs even while the list
605    /// scrolls. Without this the origin was only ever sampled from the last
606    /// pointer event and went stale the moment the field scrolled.
607    pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
608        self.refs.node_origin.clone()
609    }
610
611    /// Returns the current text.
612    pub fn text(&self) -> String {
613        self.state.text()
614    }
615
616    pub fn style(&self) -> &TextStyle {
617        &self.style
618    }
619
620    /// Returns the current selection.
621    pub fn selection(&self) -> TextRange {
622        self.state.selection()
623    }
624
625    /// Returns the cursor brush for rendering.
626    pub fn cursor_brush(&self) -> Brush {
627        self.cursor_brush.clone()
628    }
629
630    /// Returns the selection brush for rendering selection highlight.
631    pub fn selection_brush(&self) -> Brush {
632        self.selection_brush.clone()
633    }
634
635    /// Inserts text at the current cursor position (for paste operations).
636    pub fn insert_text(&mut self, text: &str) {
637        self.state.edit(|buffer| {
638            buffer.insert(text);
639        });
640    }
641
642    /// Copies the selected text and returns it (for web copy operation).
643    /// Returns None if no selection.
644    pub fn copy_selection(&self) -> Option<String> {
645        self.state.copy_selection()
646    }
647
648    /// Cuts the selected text: copies and deletes it.
649    /// Returns the cut text, or None if no selection.
650    pub fn cut_selection(&mut self) -> Option<String> {
651        let text = self.copy_selection();
652        if text.is_some() {
653            self.state.edit(|buffer| {
654                buffer.delete(buffer.selection());
655            });
656        }
657        text
658    }
659
660    /// Returns a clone of the text field state for use in draw closures.
661    /// This allows reading selection at DRAW time rather than LAYOUT time.
662    pub fn get_state(&self) -> cranpose_foundation::text::TextFieldState {
663        self.state.clone()
664    }
665
666    /// Updates the content offset (padding.left) for accurate click-to-position cursor placement.
667    /// Called from slices collection where padding is known.
668    pub fn set_content_offset(&self, offset: f32) {
669        self.refs.content_offset.set(offset);
670    }
671
672    /// Updates the content Y offset (padding.top) for cursor Y positioning.
673    /// Called from slices collection where padding is known.
674    pub fn set_content_y_offset(&self, offset: f32) {
675        self.refs.content_y_offset.set(offset);
676    }
677
678    /// The wrap width a multi-line field lays its text out at, or `None` when
679    /// the text must not wrap (single-line fields pan horizontally instead).
680    ///
681    /// Multi-line fields wrap at the available content width exactly like the
682    /// render scene builder, so the measured height reflects every wrapped line
683    /// and the field grows to fit its content instead of clipping it.
684    fn wrap_width(&self, available_width: f32) -> Option<f32> {
685        (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
686            .then_some(available_width)
687    }
688
689    /// Measures the text content using node-identity-based caching.
690    ///
691    /// `wrap_width` bounds the layout width so multi-line text wraps; `None`
692    /// measures the natural single-line width (single-line fields, intrinsic
693    /// width queries).
694    fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
695        let text = self.state.text();
696        let node_id = self.refs.node_id.get();
697        let annotated = crate::text::AnnotatedString::from(text.as_str());
698        let metrics = match wrap_width {
699            Some(max_width) => crate::text::measure_text_with_options_for_node(
700                node_id,
701                &annotated,
702                &self.style,
703                crate::text::TextLayoutOptions::default(),
704                Some(max_width),
705            ),
706            None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
707        };
708        self.measured_line_height.set(metrics.line_height);
709        Size {
710            width: metrics.width,
711            height: metrics.height,
712        }
713    }
714
715    /// Updates cached state and returns true if changed.
716    fn update_cached_state(&mut self) -> bool {
717        let value = self.state.value();
718        let text_changed = value.text != self.cached_text;
719        let selection_changed = value.selection != self.cached_selection;
720
721        if text_changed {
722            self.cached_text = value.text;
723        }
724        if selection_changed {
725            self.cached_selection = value.selection;
726        }
727
728        text_changed || selection_changed
729    }
730
731    /// Positions cursor at a given x offset within the text.
732    /// Uses proper text layout hit testing for accurate proportional font support.
733    pub fn position_cursor_at_offset(&self, x_offset: f32) {
734        let text = self.state.text();
735        if text.is_empty() {
736            self.state.edit(|buffer| {
737                buffer.place_cursor_at_start();
738            });
739            return;
740        }
741
742        // Use proper text layout hit testing instead of character-based calculation.
743        // Map the viewport-relative offset into text space by adding the pan offset.
744        let byte_offset = crate::text::get_offset_for_position(
745            &crate::text::AnnotatedString::from(text.as_str()),
746            &self.style,
747            x_offset + self.refs.scroll_offset.get(),
748            0.0,
749        );
750
751        self.state.edit(|buffer| {
752            buffer.place_cursor_before_char(byte_offset);
753        });
754    }
755
756    // NOTE: Key event handling is done via TextFieldHandler::handle_key() which is
757    // registered with the focus system for O(1) dispatch. DO NOT add a handle_key_event()
758    // method here - it would be duplicate code that never gets called.
759}
760
761impl DelegatableNode for TextFieldModifierNode {
762    fn node_state(&self) -> &NodeState {
763        &self.node_state
764    }
765}
766
767impl ModifierNode for TextFieldModifierNode {
768    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
769        // Store node_id for scoped layout invalidation (avoids O(app) global invalidation)
770        self.refs.node_id.set(context.node_id());
771
772        context.invalidate(InvalidationKind::Layout);
773        context.invalidate(InvalidationKind::Draw);
774        context.invalidate(InvalidationKind::Semantics);
775    }
776
777    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
778        Some(self)
779    }
780
781    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
782        Some(self)
783    }
784
785    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
786        Some(self)
787    }
788
789    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
790        Some(self)
791    }
792
793    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
794        Some(self)
795    }
796
797    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
798        Some(self)
799    }
800
801    fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
802        Some(self)
803    }
804
805    fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
806        Some(self)
807    }
808}
809
810impl LayoutModifierNode for TextFieldModifierNode {
811    fn measure(
812        &self,
813        _context: &mut dyn ModifierNodeContext,
814        _measurable: &dyn Measurable,
815        constraints: Constraints,
816    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
817        // Measure the text content, wrapping multi-line fields at the available
818        // width so the field grows to fit every wrapped line instead of
819        // clipping content past the first line.
820        let text_size = self.measure_text_content(self.wrap_width(constraints.max_width));
821
822        // Add minimum height for empty text (cursor needs space)
823        let min_height = if text_size.height < 1.0 {
824            DEFAULT_LINE_HEIGHT
825        } else {
826            text_size.height
827        };
828
829        // Constrain to provided constraints
830        let width = text_size
831            .width
832            .max(constraints.min_width)
833            .min(constraints.max_width);
834        let height = min_height
835            .max(constraints.min_height)
836            .min(constraints.max_height);
837
838        let size = Size { width, height };
839        self.measured_size.set(size);
840
841        // Refresh the horizontal pan offset so it is up to date for pointer
842        // input and rendering even before the next draw pass runs.
843        let _ = (self.cached_pan_resolver)(size.width);
844
845        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
846    }
847
848    fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
849        self.measure_text_content(None).width
850    }
851
852    fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
853        self.measure_text_content(None).width
854    }
855
856    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
857        self.measure_text_content(self.wrap_width(width))
858            .height
859            .max(DEFAULT_LINE_HEIGHT)
860    }
861
862    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
863        self.measure_text_content(self.wrap_width(width))
864            .height
865            .max(DEFAULT_LINE_HEIGHT)
866    }
867}
868
869impl DrawModifierNode for TextFieldModifierNode {
870    fn draw(&self, _draw_scope: &mut dyn DrawScope) {
871        // No-op: Cursor and selection are rendered via create_draw_closure() which
872        // creates DrawPrimitive::Rect directly. This enables draw-time evaluation
873        // of focus state and cursor blink timing.
874    }
875
876    fn create_draw_closure(
877        &self,
878    ) -> Option<Rc<dyn Fn(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
879    {
880        use cranpose_ui_graphics::DrawPrimitive;
881
882        // Capture state via Rc clone (cheap) for draw-time evaluation
883        let is_focused = self.refs.is_focused.clone();
884        let state = self.state.clone();
885        let content_offset = self.refs.content_offset.clone();
886        let content_y_offset = self.refs.content_y_offset.clone();
887        let cursor_brush = self.cursor_brush.clone();
888        let selection_brush = self.selection_brush.clone();
889        let style = self.style.clone();
890        let cached_line_height = self.measured_line_height.clone();
891        let measured_size = self.measured_size.clone();
892        let pan_resolver = self.cached_pan_resolver.clone();
893        let handle_controller = self.handle_controller.clone();
894        let node_origin = self.refs.node_origin.clone();
895        let last_pointer_source = self.refs.last_pointer_source.clone();
896
897        Some(Rc::new(move |size| {
898            // Check focus at DRAW time
899            if !*is_focused.borrow() {
900                // Publish an unfocused snapshot so the composable clears any
901                // finger handles when the field loses focus.
902                if let Some(controller) = &handle_controller {
903                    controller.publish(TextFieldHandleMetrics {
904                        focused: false,
905                        touch: false,
906                        node_origin: node_origin.get(),
907                        padding_left: 0.0,
908                        padding_top: 0.0,
909                        scroll_offset: 0.0,
910                        line_height: cached_line_height.get(),
911                    });
912                }
913                return vec![];
914            }
915
916            let mut primitives = Vec::new();
917
918            let text = state.text();
919            let selection = state.selection();
920            let padding_left = content_offset.get();
921            let padding_top = content_y_offset.get();
922            // Reuse line_height from the most recent layout measurement
923            // instead of re-measuring the full text.
924            let line_height = cached_line_height.get();
925
926            // Content viewport (excludes padding). Fall back to the node size
927            // when measurement has not run yet.
928            let measured = measured_size.get();
929            let viewport_width = if measured.width > 0.0 {
930                measured.width
931            } else {
932                (size.width - padding_left).max(0.0)
933            };
934            let viewport_height = if measured.height > 0.0 {
935                measured.height
936            } else {
937                (size.height - padding_top).max(0.0)
938            };
939            // Horizontal pan that keeps the cursor visible (0 for multi-line).
940            let pan = pan_resolver(viewport_width);
941
942            // Publish live geometry so the `BasicTextField` composable can place
943            // and drive the finger selection handles.
944            if let Some(controller) = &handle_controller {
945                controller.publish(TextFieldHandleMetrics {
946                    focused: true,
947                    touch: last_pointer_source.get().is_touch_like(),
948                    node_origin: node_origin.get(),
949                    padding_left,
950                    padding_top,
951                    scroll_offset: pan,
952                    line_height,
953                });
954            }
955            // Everything the field draws (selection, IME underline, cursor)
956            // is clipped to the content viewport so primitives never extend
957            // outside the field bounds.
958            let clip_bounds = cranpose_ui_graphics::Rect {
959                x: padding_left,
960                y: padding_top,
961                width: viewport_width,
962                height: viewport_height,
963            };
964
965            // Draw selection highlight
966            if !selection.collapsed() {
967                let sel_start = selection.min();
968                let sel_end = selection.max();
969
970                let lines: Vec<&str> = text.split('\n').collect();
971                let mut byte_offset: usize = 0;
972
973                for (line_idx, line) in lines.iter().enumerate() {
974                    let line_start = byte_offset;
975                    let line_end = byte_offset + line.len();
976
977                    if sel_end > line_start && sel_start < line_end {
978                        let sel_start_in_line = sel_start.saturating_sub(line_start);
979                        let sel_end_in_line = (sel_end - line_start).min(line.len());
980
981                        let sel_start_x = crate::text::measure_text(
982                            &crate::text::AnnotatedString::from(&line[..sel_start_in_line]),
983                            &style,
984                        )
985                        .width
986                            + padding_left
987                            - pan;
988                        let sel_end_x = crate::text::measure_text(
989                            &crate::text::AnnotatedString::from(&line[..sel_end_in_line]),
990                            &style,
991                        )
992                        .width
993                            + padding_left
994                            - pan;
995                        let sel_width = sel_end_x - sel_start_x;
996
997                        if sel_width > 0.0 {
998                            let sel_rect = cranpose_ui_graphics::Rect {
999                                x: sel_start_x,
1000                                y: padding_top + line_idx as f32 * line_height,
1001                                width: sel_width,
1002                                height: line_height,
1003                            };
1004                            if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1005                                primitives.push(DrawPrimitive::Rect {
1006                                    rect: clipped,
1007                                    brush: selection_brush.clone(),
1008                                });
1009                            }
1010                        }
1011                    }
1012                    byte_offset = line_end + 1;
1013                }
1014            }
1015
1016            // Draw composition (IME preedit) underline
1017            // This shows the user which text is being composed by the input method
1018            if let Some(comp_range) = state.composition() {
1019                let comp_start = comp_range.min();
1020                let comp_end = comp_range.max();
1021
1022                if comp_start < comp_end && comp_end <= text.len() {
1023                    let lines: Vec<&str> = text.split('\n').collect();
1024                    let mut byte_offset: usize = 0;
1025
1026                    // Underline color: slightly transparent white/gray
1027                    let underline_brush = cranpose_ui_graphics::Brush::solid(
1028                        cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
1029                    );
1030                    let underline_height: f32 = 2.0;
1031
1032                    for (line_idx, line) in lines.iter().enumerate() {
1033                        let line_start = byte_offset;
1034                        let line_end = byte_offset + line.len();
1035
1036                        // Check if composition overlaps this line
1037                        if comp_end > line_start && comp_start < line_end {
1038                            let comp_start_in_line = comp_start.saturating_sub(line_start);
1039                            let comp_end_in_line = (comp_end - line_start).min(line.len());
1040
1041                            // Clamp to valid UTF-8 boundaries
1042                            let comp_start_in_line = if line.is_char_boundary(comp_start_in_line) {
1043                                comp_start_in_line
1044                            } else {
1045                                0
1046                            };
1047                            let comp_end_in_line = if line.is_char_boundary(comp_end_in_line) {
1048                                comp_end_in_line
1049                            } else {
1050                                line.len()
1051                            };
1052
1053                            let comp_start_x = crate::text::measure_text(
1054                                &crate::text::AnnotatedString::from(&line[..comp_start_in_line]),
1055                                &style,
1056                            )
1057                            .width
1058                                + padding_left
1059                                - pan;
1060                            let comp_end_x = crate::text::measure_text(
1061                                &crate::text::AnnotatedString::from(&line[..comp_end_in_line]),
1062                                &style,
1063                            )
1064                            .width
1065                                + padding_left
1066                                - pan;
1067                            let comp_width = comp_end_x - comp_start_x;
1068
1069                            if comp_width > 0.0 {
1070                                // Draw underline at the bottom of the text line
1071                                let underline_rect = cranpose_ui_graphics::Rect {
1072                                    x: comp_start_x,
1073                                    y: padding_top + (line_idx as f32 + 1.0) * line_height
1074                                        - underline_height,
1075                                    width: comp_width,
1076                                    height: underline_height,
1077                                };
1078                                if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1079                                    primitives.push(DrawPrimitive::Rect {
1080                                        rect: clipped,
1081                                        brush: underline_brush.clone(),
1082                                    });
1083                                }
1084                            }
1085                        }
1086                        byte_offset = line_end + 1;
1087                    }
1088                }
1089            }
1090
1091            // Draw cursor - check visibility at DRAW time for blinking
1092            if crate::cursor_animation::is_cursor_visible() {
1093                let pos = selection.start.min(text.len());
1094                let text_before = &text[..pos];
1095                let line_index = text_before.matches('\n').count();
1096                let line_start = text_before.rfind('\n').map(|i| i + 1).unwrap_or(0);
1097                let cursor_x = crate::text::measure_text(
1098                    &crate::text::AnnotatedString::from(&text_before[line_start..]),
1099                    &style,
1100                )
1101                .width
1102                    + padding_left
1103                    - pan;
1104                let cursor_y = padding_top + line_index as f32 * line_height;
1105
1106                let cursor_rect = cranpose_ui_graphics::Rect {
1107                    x: cursor_x,
1108                    y: cursor_y,
1109                    width: CURSOR_WIDTH,
1110                    height: line_height,
1111                };
1112
1113                if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1114                    primitives.push(DrawPrimitive::Rect {
1115                        rect: clipped,
1116                        brush: cursor_brush.clone(),
1117                    });
1118                }
1119            }
1120
1121            primitives
1122        }))
1123    }
1124}
1125
1126impl SemanticsNode for TextFieldModifierNode {
1127    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1128        let text = self.state.text();
1129        config.content_description = Some(text);
1130        config.is_editable_text = true;
1131        config.text_selection = Some(self.state.selection());
1132    }
1133}
1134
1135impl PointerInputNode for TextFieldModifierNode {
1136    fn on_pointer_event(
1137        &mut self,
1138        _context: &mut dyn ModifierNodeContext,
1139        _event: &PointerEvent,
1140    ) -> bool {
1141        // No-op: All pointer handling is done via pointer_input_handler() closure.
1142        // This follows Jetpack Compose's delegation pattern where the node simply
1143        // forwards to a delegated pointer input handler (see TextFieldDecoratorModifier.kt:741-747).
1144        //
1145        // The cached_handler closure handles:
1146        // - Focus request on Down
1147        // - Cursor positioning
1148        // - Double-click word selection
1149        // - Triple-click select all
1150        // - Drag selection
1151        false
1152    }
1153
1154    fn hit_test(&self, x: f32, y: f32) -> bool {
1155        // Check if point is within measured bounds
1156        let size = self.measured_size.get();
1157        x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1158    }
1159
1160    fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1161        // Return cached handler for pointer input dispatch
1162        Some(self.cached_handler.clone())
1163    }
1164}
1165
1166// ============================================================================
1167// TextFieldElement - Creates and updates TextFieldModifierNode
1168// ============================================================================
1169
1170/// Element that creates and updates `TextFieldModifierNode` instances.
1171///
1172/// This follows the modifier element pattern where the element is responsible for:
1173/// - Creating new nodes (via `create`)
1174/// - Updating existing nodes when properties change (via `update`)
1175/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
1176#[derive(Clone)]
1177pub struct TextFieldElement {
1178    /// The text field state
1179    state: TextFieldState,
1180    /// Text style
1181    style: TextStyle,
1182    /// Cursor color
1183    cursor_color: Color,
1184    /// Line limits configuration
1185    line_limits: TextFieldLineLimits,
1186    /// Channel the node publishes live handle metrics to (finger selection
1187    /// handles). `None` disables handle support.
1188    handle_controller: Option<TextFieldHandleController>,
1189}
1190
1191impl TextFieldElement {
1192    /// Creates a new text field element.
1193    pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1194        Self {
1195            state,
1196            style,
1197            cursor_color: DEFAULT_CURSOR_COLOR,
1198            line_limits: TextFieldLineLimits::default(),
1199            handle_controller: None,
1200        }
1201    }
1202
1203    /// Creates an element with custom cursor color.
1204    pub fn with_cursor_color(mut self, color: Color) -> Self {
1205        self.cursor_color = color;
1206        self
1207    }
1208
1209    /// Creates an element with custom line limits.
1210    pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1211        self.line_limits = line_limits;
1212        self
1213    }
1214
1215    /// Installs the finger-handle metrics channel shared with the composable.
1216    pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1217        self.handle_controller = Some(controller);
1218        self
1219    }
1220}
1221
1222impl std::fmt::Debug for TextFieldElement {
1223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1224        f.debug_struct("TextFieldElement")
1225            .field("text", &self.state.text())
1226            .field("style", &self.style)
1227            .field("cursor_color", &self.cursor_color)
1228            .finish()
1229    }
1230}
1231
1232impl Hash for TextFieldElement {
1233    fn hash<H: Hasher>(&self, state: &mut H) {
1234        // Hash by state Rc pointer identity - matches PartialEq
1235        // This ensures equal elements hash equal (correctness requirement)
1236        std::ptr::hash(std::rc::Rc::as_ptr(&self.state.inner), state);
1237        // Hash cursor color
1238        self.cursor_color.0.to_bits().hash(state);
1239        self.cursor_color.1.to_bits().hash(state);
1240        self.cursor_color.2.to_bits().hash(state);
1241        self.cursor_color.3.to_bits().hash(state);
1242        self.style.render_hash().hash(state);
1243        self.line_limits.hash(state);
1244    }
1245}
1246
1247impl PartialEq for TextFieldElement {
1248    fn eq(&self, other: &Self) -> bool {
1249        // Compare by state identity (same Rc), cursor color, and line limits
1250        // This ensures node reuse when same state is passed, while detecting
1251        // actual changes that require updates
1252        self.state == other.state
1253            && self.style == other.style
1254            && self.cursor_color == other.cursor_color
1255            && self.line_limits == other.line_limits
1256    }
1257}
1258
1259impl Eq for TextFieldElement {}
1260
1261impl ModifierNodeElement for TextFieldElement {
1262    type Node = TextFieldModifierNode;
1263
1264    fn create(&self) -> Self::Node {
1265        let mut node = TextFieldModifierNode::new(self.state.clone(), self.style.clone())
1266            .with_cursor_color(self.cursor_color)
1267            .with_line_limits(self.line_limits);
1268        if let Some(controller) = self.handle_controller.clone() {
1269            node = node.with_handle_controller(controller);
1270        }
1271        node
1272    }
1273
1274    fn update(&self, node: &mut Self::Node) {
1275        // Update the state reference
1276        node.state = self.state.clone();
1277        node.style = self.style.clone();
1278        node.cursor_brush = Brush::solid(self.cursor_color);
1279        node.line_limits = self.line_limits;
1280        node.handle_controller = self.handle_controller.clone();
1281
1282        // Recreate the cached handler with the new state but same refs
1283        node.cached_handler = TextFieldModifierNode::create_handler(
1284            node.state.clone(),
1285            node.refs.clone(),
1286            node.line_limits,
1287            self.style.clone(),
1288        );
1289
1290        // Recreate the pan resolver so it captures the new state/style/limits
1291        node.cached_pan_resolver = TextFieldModifierNode::create_pan_resolver(
1292            node.state.clone(),
1293            node.refs.clone(),
1294            node.line_limits,
1295            self.style.clone(),
1296        );
1297
1298        // Check if content changed and update cache
1299        if node.update_cached_state() {
1300            // Content changed - node will need layout/draw invalidation
1301            // This happens automatically through the modifier reconciliation
1302        }
1303    }
1304
1305    fn capabilities(&self) -> NodeCapabilities {
1306        NodeCapabilities::LAYOUT
1307            | NodeCapabilities::DRAW
1308            | NodeCapabilities::SEMANTICS
1309            | NodeCapabilities::POINTER_INPUT
1310    }
1311
1312    fn always_update(&self) -> bool {
1313        // Always update to capture new state/handler while preserving focus state
1314        true
1315    }
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320    use super::*;
1321    use crate::text::TextStyle;
1322    use cranpose_core::{DefaultScheduler, Runtime};
1323    use std::sync::Arc;
1324
1325    /// Sets up a test runtime and keeps it alive for the duration of the test.
1326    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1327        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1328        f()
1329    }
1330
1331    #[test]
1332    fn text_field_node_creation() {
1333        let _app_context = crate::render_state::app_context_test_scope();
1334        with_test_runtime(|| {
1335            let state = TextFieldState::new("Hello");
1336            let node = TextFieldModifierNode::new(state, TextStyle::default());
1337            assert_eq!(node.text(), "Hello");
1338            assert!(!node.is_focused());
1339        });
1340    }
1341
1342    #[test]
1343    fn text_field_node_focus() {
1344        let _app_context = crate::render_state::app_context_test_scope();
1345        with_test_runtime(|| {
1346            let state = TextFieldState::new("Test");
1347            let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1348            assert!(!node.is_focused());
1349
1350            node.set_focused(true);
1351            assert!(node.is_focused());
1352
1353            node.set_focused(false);
1354            assert!(!node.is_focused());
1355        });
1356    }
1357
1358    #[test]
1359    fn text_field_element_creates_node() {
1360        let _app_context = crate::render_state::app_context_test_scope();
1361        with_test_runtime(|| {
1362            let state = TextFieldState::new("Hello World");
1363            let element = TextFieldElement::new(state, TextStyle::default());
1364
1365            let node = element.create();
1366            assert_eq!(node.text(), "Hello World");
1367        });
1368    }
1369
1370    /// End-to-end guard for the touch-vs-mouse finger-handle pipeline through
1371    /// the real pointer handler and draw closure: a Touch-source press makes the
1372    /// focused field publish `touch = true` (so `SelectionHandles` shows the
1373    /// finger cursor/selection handles and the Copy/Cut/Paste popup), while a
1374    /// Mouse-source press publishes `touch = false` (a clean caret, no handles).
1375    /// The published `touch` flag is exactly what the overlay is gated on, so
1376    /// this pins the source → `last_pointer_source` → metrics link the Android
1377    /// touch handles depend on.
1378    #[test]
1379    fn touch_press_publishes_touch_handle_metrics_but_mouse_does_not() {
1380        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1381        use cranpose_ui_graphics::Point;
1382
1383        let _app_context = crate::render_state::app_context_test_scope();
1384        with_test_runtime(|| {
1385            let state = TextFieldState::new("hello world");
1386            let controller = TextFieldHandleController::new();
1387            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1388                .with_handle_controller(controller.clone());
1389            // Give the field a measured size so the draw closure has geometry.
1390            node.measured_size.set(Size {
1391                width: 120.0,
1392                height: 20.0,
1393            });
1394
1395            let handler = node
1396                .pointer_input_handler()
1397                .expect("field exposes a pointer handler");
1398            let draw = node
1399                .create_draw_closure()
1400                .expect("field exposes a draw closure");
1401            let at = Point { x: 12.0, y: 8.0 };
1402            let size = Size {
1403                width: 120.0,
1404                height: 20.0,
1405            };
1406
1407            // Touch tap: the field focuses and remembers the touch source, so
1408            // its draw closure publishes touch = true.
1409            handler(
1410                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1411            );
1412            let _ = draw(size);
1413            let metrics = controller
1414                .metrics()
1415                .expect("focused field publishes handle metrics");
1416            assert!(metrics.focused, "a tap focuses the field");
1417            assert!(
1418                metrics.touch,
1419                "a touch tap must publish touch = true so the finger handles show"
1420            );
1421
1422            // Mouse tap on the same field: the source flips to mouse, so the
1423            // field publishes touch = false (clean caret, no finger handles).
1424            handler(
1425                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1426            );
1427            let _ = draw(size);
1428            let metrics = controller
1429                .metrics()
1430                .expect("focused field publishes handle metrics");
1431            assert!(
1432                !metrics.touch,
1433                "a mouse tap must publish touch = false (clean caret, no finger handle)"
1434            );
1435
1436            crate::text_field_focus::clear_focus();
1437        });
1438    }
1439
1440    /// A double tap on a word must select that word. This regressed after
1441    /// selection handles began appearing inside `LazyColumn` items in 0.1.39:
1442    /// the cursor handle shown by the first tap overlapped the text line and
1443    /// consumed the second tap. The field's own gesture classification (proven
1444    /// here) is correct — two quick taps at the same spot escalate to a word
1445    /// selection — so the fix is geometric (keep the handle's touch box off the
1446    /// text line; see `selection_handle::handle_shape`).
1447    #[test]
1448    fn double_tap_selects_the_word_under_the_finger() {
1449        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1450        use cranpose_ui_graphics::Point;
1451
1452        let _app_context = crate::render_state::app_context_test_scope();
1453        with_test_runtime(|| {
1454            let state = TextFieldState::new("hello world");
1455            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1456            node.measured_size.set(Size {
1457                width: 200.0,
1458                height: 20.0,
1459            });
1460            let handler = node
1461                .pointer_input_handler()
1462                .expect("field exposes a pointer handler");
1463
1464            // Two touch taps at the same spot, back to back (well within the
1465            // multi-tap timeout and slop): near the start of "hello".
1466            let at = Point { x: 2.0, y: 8.0 };
1467            handler(
1468                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1469            );
1470            handler(
1471                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1472            );
1473
1474            let selection = state.selection();
1475            assert!(
1476                !selection.collapsed(),
1477                "a double tap must produce a (word) selection, got {selection:?}"
1478            );
1479            let selected = &state.text()[selection.min()..selection.max()];
1480            assert_eq!(
1481                selected, "hello",
1482                "double tap should select the whole word under the finger"
1483            );
1484
1485            crate::text_field_focus::clear_focus();
1486        });
1487    }
1488
1489    /// The multi-tap selection granularity ladder (bug 8): repeated in-place taps
1490    /// escalate word → line → paragraph, then cycle back to word. Mirrors mature
1491    /// editors (Android `TextView`, iOS, VS Code).
1492    #[test]
1493    fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1494        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1495        use cranpose_ui_graphics::Point;
1496
1497        let _app_context = crate::render_state::app_context_test_scope();
1498        with_test_runtime(|| {
1499            // Two lines in the first paragraph, a blank line, then a second
1500            // paragraph — so line and paragraph selections differ.
1501            let text = "alpha beta\ngamma delta\n\nsecond para";
1502            let state = TextFieldState::new(text);
1503            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1504                .with_line_limits(TextFieldLineLimits::MultiLine {
1505                    min_lines: 1,
1506                    max_lines: usize::MAX,
1507                });
1508            node.measured_size.set(Size {
1509                width: 400.0,
1510                height: 80.0,
1511            });
1512            let handler = node
1513                .pointer_input_handler()
1514                .expect("field exposes a pointer handler");
1515
1516            // Tap in place on the first line ("alpha").
1517            let at = Point { x: 2.0, y: 4.0 };
1518            let tap = || {
1519                handler(
1520                    PointerEvent::new(PointerEventKind::Down, at, at)
1521                        .with_source(PointerSource::Touch),
1522                );
1523            };
1524            let selected = |state: &TextFieldState| {
1525                let s = state.selection();
1526                state.text()[s.min()..s.max()].to_string()
1527            };
1528
1529            tap(); // 1 → caret
1530            assert!(state.selection().collapsed(), "first tap places the caret");
1531            tap(); // 2 → word
1532            assert_eq!(selected(&state), "alpha", "double tap selects the word");
1533            tap(); // 3 → line
1534            assert_eq!(
1535                selected(&state),
1536                "alpha beta",
1537                "triple tap selects the line"
1538            );
1539            tap(); // 4 → paragraph
1540            assert_eq!(
1541                selected(&state),
1542                "alpha beta\ngamma delta",
1543                "fourth tap grows to the paragraph"
1544            );
1545            tap(); // 5 → cycles back to word
1546            assert_eq!(
1547                selected(&state),
1548                "alpha",
1549                "fifth tap cycles back to the word"
1550            );
1551
1552            crate::text_field_focus::clear_focus();
1553        });
1554    }
1555
1556    /// A single tap that lands inside an existing selection re-grabs the word
1557    /// under the finger (Android/iOS behaviour), rather than collapsing to a
1558    /// caret (bug 8).
1559    #[test]
1560    fn single_tap_inside_selection_selects_the_word() {
1561        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1562        use cranpose_ui_graphics::Point;
1563
1564        let _app_context = crate::render_state::app_context_test_scope();
1565        with_test_runtime(|| {
1566            let state = TextFieldState::new("hello world");
1567            let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1568            node.measured_size.set(Size {
1569                width: 200.0,
1570                height: 20.0,
1571            });
1572            let handler = node
1573                .pointer_input_handler()
1574                .expect("field exposes a pointer handler");
1575
1576            // Pre-existing broad selection over the whole text.
1577            state.edit(|buffer| buffer.select(TextRange::new(0, 11)));
1578            assert!(!state.selection().collapsed());
1579
1580            // A lone tap over "hello" (fresh tap count) must select that word,
1581            // not drop the selection.
1582            let at = Point { x: 2.0, y: 8.0 };
1583            handler(
1584                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1585            );
1586
1587            let selection = state.selection();
1588            assert!(
1589                !selection.collapsed(),
1590                "a tap inside a selection must not collapse it, got {selection:?}"
1591            );
1592            assert_eq!(
1593                &state.text()[selection.min()..selection.max()],
1594                "hello",
1595                "a tap inside a selection re-selects the word under the finger"
1596            );
1597
1598            crate::text_field_focus::clear_focus();
1599        });
1600    }
1601
1602    #[test]
1603    fn text_field_element_equality() {
1604        let _app_context = crate::render_state::app_context_test_scope();
1605        with_test_runtime(|| {
1606            let state1 = TextFieldState::new("Hello");
1607            let state2 = TextFieldState::new("Hello"); // Different Rc, same text
1608
1609            let elem1 = TextFieldElement::new(state1.clone(), TextStyle::default());
1610            let elem2 = TextFieldElement::new(state1.clone(), TextStyle::default()); // Same state (Rc identity)
1611            let elem3 = TextFieldElement::new(state2, TextStyle::default()); // Different state
1612
1613            // Elements are equal only when they share the same state Rc
1614            // This ensures proper Eq/Hash contract compliance
1615            assert_eq!(elem1, elem2, "Same state should be equal");
1616            assert_ne!(elem1, elem3, "Different states should not be equal");
1617        });
1618    }
1619
1620    #[test]
1621    fn text_field_element_update_refreshes_existing_node_style() {
1622        let _app_context = crate::render_state::app_context_test_scope();
1623        with_test_runtime(|| {
1624            let state = TextFieldState::new("themed text");
1625            let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
1626                color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
1627                ..crate::text::SpanStyle::default()
1628            });
1629            let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
1630                color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
1631                ..crate::text::SpanStyle::default()
1632            });
1633            let initial = TextFieldElement::new(state.clone(), dark_style);
1634            let updated = TextFieldElement::new(state, light_style.clone());
1635            let mut node = initial.create();
1636
1637            updated.update(&mut node);
1638
1639            assert_eq!(node.text(), "themed text");
1640            assert_eq!(node.style(), &light_style);
1641        });
1642    }
1643
1644    /// A multi-line field must measure the *wrapped* height at the available
1645    /// width, so a long transcript grows the field instead of being clipped to
1646    /// a single line. Regression for the "edits only appear after focus loss"
1647    /// bug where a wrapped OCR transcript rendered only its first line.
1648    #[test]
1649    fn multiline_field_measures_wrapped_height() {
1650        let _app_context = crate::render_state::app_context_test_scope();
1651        with_test_runtime(|| {
1652            let long = "abcd ".repeat(40); // ~200 chars, no explicit newlines
1653            let state = TextFieldState::new(&long);
1654            let node = TextFieldModifierNode::new(state, TextStyle::default());
1655            assert!(
1656                !node.line_limits().is_single_line(),
1657                "default fields are multi-line"
1658            );
1659
1660            let natural = node.measure_text_content(None);
1661            let wrapped = node.measure_text_content(node.wrap_width(20.0));
1662
1663            assert!(
1664                wrapped.height > natural.height,
1665                "wrapped multi-line height {} must exceed the single-line height {}",
1666                wrapped.height,
1667                natural.height
1668            );
1669        });
1670    }
1671
1672    /// Single-line fields pan horizontally instead of wrapping, so they never
1673    /// derive a wrap width even under a narrow constraint.
1674    #[test]
1675    fn single_line_field_never_wraps() {
1676        let _app_context = crate::render_state::app_context_test_scope();
1677        with_test_runtime(|| {
1678            let state = TextFieldState::new("abcd ".repeat(40));
1679            let node = TextFieldModifierNode::new(state, TextStyle::default())
1680                .with_line_limits(TextFieldLineLimits::SingleLine);
1681            assert_eq!(
1682                node.wrap_width(20.0),
1683                None,
1684                "single-line fields must not wrap"
1685            );
1686        });
1687    }
1688
1689    /// Test that cursor draw command position is calculated correctly.
1690    ///
1691    /// This test verifies that when we measure text width for cursor position:
1692    /// 1. The cursor x position = width of text before cursor
1693    /// 2. For text at cursor end, x = full text width
1694    #[test]
1695    fn test_cursor_x_position_calculation() {
1696        let _app_context = crate::render_state::app_context_test_scope();
1697        with_test_runtime(|| {
1698            // Test that text measurement works correctly for cursor positioning
1699            let style = crate::text::TextStyle::default();
1700
1701            // Empty text - cursor should be at x=0
1702            let empty_width =
1703                crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
1704            assert!(
1705                empty_width.abs() < 0.1,
1706                "Empty text should have 0 width, got {}",
1707                empty_width
1708            );
1709
1710            // Non-empty text - cursor at end should be at text width
1711            let hi_width =
1712                crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
1713            assert!(
1714                hi_width > 0.0,
1715                "Text 'Hi' should have positive width: {}",
1716                hi_width
1717            );
1718
1719            // Partial text - cursor after 'H' should be at width of 'H'
1720            let h_width =
1721                crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
1722            assert!(h_width > 0.0, "Text 'H' should have positive width");
1723            assert!(
1724                h_width < hi_width,
1725                "'H' width {} should be less than 'Hi' width {}",
1726                h_width,
1727                hi_width
1728            );
1729
1730            // Verify TextFieldState selection tracks cursor correctly
1731            let state = TextFieldState::new("Hi");
1732            assert_eq!(
1733                state.selection().start,
1734                2,
1735                "Cursor should be at position 2 (end of 'Hi')"
1736            );
1737
1738            // The text before cursor at position 2 in "Hi" is "Hi" itself
1739            let text = state.text();
1740            let cursor_pos = state.selection().start;
1741            let text_before_cursor = &text[..cursor_pos.min(text.len())];
1742            assert_eq!(text_before_cursor, "Hi");
1743
1744            // So cursor x = width of "Hi"
1745            let cursor_x = crate::text::measure_text(
1746                &crate::text::AnnotatedString::from(text_before_cursor),
1747                &style,
1748            )
1749            .width;
1750            assert!(
1751                (cursor_x - hi_width).abs() < 0.1,
1752                "Cursor x {} should equal 'Hi' width {}",
1753                cursor_x,
1754                hi_width
1755            );
1756        });
1757    }
1758
1759    /// Test cursor is created when focused node is in slices.
1760    #[test]
1761    fn test_focused_node_creates_cursor() {
1762        let _app_context = crate::render_state::app_context_test_scope();
1763        with_test_runtime(|| {
1764            let state = TextFieldState::new("Test");
1765            let element = TextFieldElement::new(state.clone(), TextStyle::default());
1766            let node = element.create();
1767
1768            // Initially not focused
1769            assert!(!node.is_focused());
1770
1771            // Set focus
1772            *node.refs.is_focused.borrow_mut() = true;
1773            assert!(node.is_focused());
1774
1775            // Verify the node has correct text
1776            assert_eq!(node.text(), "Test");
1777
1778            // Verify selection is at end
1779            assert_eq!(node.selection().start, 4);
1780        });
1781    }
1782}