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