Skip to main content

cranpose_ui/
text_field_modifier_node.rs

1use std::{
2    cell::{Cell, RefCell},
3    hash::{Hash, Hasher},
4    rc::Rc,
5};
6
7use cranpose_core::{MutableState, mutableStateOf};
8use cranpose_foundation::{
9    Constraints, DelegatableNode, DrawModifierNode, DrawScope, InvalidationKind,
10    LayoutModifierNode, Measurable, ModifierNode, ModifierNodeContext, ModifierNodeElement,
11    NodeCapabilities, NodeState, PointerEvent, PointerEventKind, PointerInputNode,
12    SemanticsConfiguration, SemanticsNode, Size,
13    text::{TextFieldLineLimits, TextFieldState, TextRange},
14};
15use cranpose_ui_graphics::{Brush, Color, Point};
16
17#[derive(Clone, Copy, PartialEq, Debug)]
18pub struct TextFieldHandleMetrics {
19    pub focused: bool,
20    pub direct_manipulation: bool,
21    pub node_origin: Point,
22    pub padding_left: f32,
23    pub padding_top: f32,
24    pub scroll_offset: f32,
25    pub line_height: f32,
26    pub glyph_box: (f32, f32),
27    pub wrap_width: Option<f32>,
28}
29
30#[derive(Clone)]
31pub struct TextFieldHandleController {
32    inner: Rc<TextFieldHandleControllerInner>,
33}
34
35impl PartialEq for TextFieldHandleController {
36    fn eq(&self, other: &Self) -> bool {
37        Rc::ptr_eq(&self.inner, &other.inner)
38    }
39}
40
41struct TextFieldHandleControllerInner {
42    metrics: Cell<Option<TextFieldHandleMetrics>>,
43    revision: MutableState<u64>,
44    gesture_claim: RefCell<Option<Rc<Cell<bool>>>>,
45    press_track: Cell<Option<MutableState<Option<PointerPressTrack>>>>,
46}
47
48impl TextFieldHandleController {
49    pub fn new() -> Self {
50        Self {
51            inner: Rc::new(TextFieldHandleControllerInner {
52                metrics: Cell::new(None),
53                revision: mutableStateOf(0u64),
54                gesture_claim: RefCell::new(None),
55                press_track: Cell::new(None),
56            }),
57        }
58    }
59
60    pub(crate) fn publish(&self, metrics: TextFieldHandleMetrics) {
61        if self.inner.metrics.get() != Some(metrics) {
62            self.inner.metrics.set(Some(metrics));
63            self.inner
64                .revision
65                .update(|value| *value = value.wrapping_add(1));
66        }
67    }
68
69    pub fn metrics(&self) -> Option<TextFieldHandleMetrics> {
70        let _ = self.inner.revision.value();
71        self.inner.metrics.get()
72    }
73
74    pub(crate) fn adopt_gesture_claim(&self, claim: &Rc<Cell<bool>>) {
75        let mut slot = self.inner.gesture_claim.borrow_mut();
76        let adopted = slot.as_ref().is_some_and(|held| Rc::ptr_eq(held, claim));
77        if !adopted {
78            *slot = Some(Rc::clone(claim));
79        }
80    }
81
82    pub(crate) fn adopt_press_track(&self, press_track: MutableState<Option<PointerPressTrack>>) {
83        if self.inner.press_track.get() != Some(press_track) {
84            self.inner.press_track.set(Some(press_track));
85            self.inner
86                .revision
87                .update(|value| *value = value.wrapping_add(1));
88        }
89    }
90
91    pub fn press(&self) -> Option<PointerPressTrack> {
92        self.inner.press_track.get().and_then(|state| state.get())
93    }
94
95    pub fn claim_gesture(&self) {
96        if let Some(claim) = self.inner.gesture_claim.borrow().as_ref() {
97            claim.set(true);
98        }
99    }
100
101    pub fn gesture_claimed(&self) -> bool {
102        self.inner
103            .gesture_claim
104            .borrow()
105            .as_ref()
106            .is_some_and(|claim| claim.get())
107    }
108}
109
110impl Default for TextFieldHandleController {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116const DEFAULT_CURSOR_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0);
117
118const DEFAULT_SELECTION_COLOR: Color = Color(0.0, 0.5, 1.0, 0.3);
119
120const DEFAULT_LINE_HEIGHT: f32 = 20.0;
121
122const CURSOR_WIDTH: f32 = 2.0;
123
124pub(crate) fn compute_horizontal_scroll_offset(
125    current_offset: f32,
126    cursor_x: f32,
127    text_width: f32,
128    viewport_width: f32,
129) -> f32 {
130    if viewport_width <= 0.0 {
131        return 0.0;
132    }
133    let max_offset = (text_width + CURSOR_WIDTH - viewport_width).max(0.0);
134    let mut offset = current_offset.clamp(0.0, max_offset);
135    let visible_end = offset + viewport_width - CURSOR_WIDTH;
136    if cursor_x > visible_end {
137        offset = cursor_x - viewport_width + CURSOR_WIDTH;
138    } else if cursor_x < offset {
139        offset = cursor_x;
140    }
141    offset.clamp(0.0, max_offset)
142}
143
144pub(crate) fn intersect_rect(
145    rect: cranpose_ui_graphics::Rect,
146    bounds: cranpose_ui_graphics::Rect,
147) -> Option<cranpose_ui_graphics::Rect> {
148    let x0 = rect.x.max(bounds.x);
149    let y0 = rect.y.max(bounds.y);
150    let x1 = (rect.x + rect.width).min(bounds.x + bounds.width);
151    let y1 = (rect.y + rect.height).min(bounds.y + bounds.height);
152    (x1 > x0 && y1 > y0).then_some(cranpose_ui_graphics::Rect {
153        x: x0,
154        y: y0,
155        width: x1 - x0,
156        height: y1 - y0,
157    })
158}
159
160/// Resolver that recomputes (and stores) the horizontal pan offset for a
161/// text field given the current content viewport width in px.
162pub type TextPanResolver = Rc<dyn Fn(f32) -> f32>;
163
164pub(crate) fn caret_visual_line_for_offset(
165    text: &str,
166    style: &TextStyle,
167    node_id: Option<cranpose_core::NodeId>,
168    wrap_width: Option<f32>,
169    offset: usize,
170    affinity: crate::text_selection::LineAffinity,
171) -> (usize, usize) {
172    let offset = offset.min(text.len());
173    match wrap_width {
174        Some(width) if width.is_finite() && width > 0.0 => {
175            let annotated = crate::text::AnnotatedString::from(text);
176            let ranges = crate::text::wrapped_line_ranges(
177                node_id,
178                &annotated,
179                style,
180                crate::text::TextLayoutOptions::default(),
181                Some(width),
182            );
183            crate::text_selection::caret_visual_line(&ranges, offset, affinity)
184        }
185        _ => {
186            let before = &text[..offset];
187            let line_index = before.matches('\n').count();
188            let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
189            (line_index, line_start)
190        }
191    }
192}
193
194#[allow(clippy::too_many_arguments)]
195pub(crate) fn range_visual_line_rects(
196    text: &str,
197    style: &TextStyle,
198    node_id: Option<cranpose_core::NodeId>,
199    wrap_width: Option<f32>,
200    padding_left: f32,
201    padding_top: f32,
202    pan: f32,
203    line_height: f32,
204    start: usize,
205    end: usize,
206) -> Vec<cranpose_ui_graphics::Rect> {
207    if start >= end {
208        return Vec::new();
209    }
210    let annotated = crate::text::AnnotatedString::from(text);
211    let line_ranges = crate::text::wrapped_line_ranges(
212        node_id,
213        &annotated,
214        style,
215        crate::text::TextLayoutOptions::default(),
216        wrap_width,
217    );
218    let mut rects = Vec::new();
219    for (line_idx, line_range) in line_ranges.iter().enumerate() {
220        let line_start = line_range.start;
221        let line_end = line_range.end;
222        if end <= line_start || start >= line_end {
223            continue;
224        }
225        let seg_start = start.max(line_start);
226        let seg_end = end.min(line_end);
227        let x0 = crate::text::measure_text(
228            &crate::text::AnnotatedString::from(&text[line_start..seg_start]),
229            style,
230        )
231        .width
232            + padding_left
233            - pan;
234        let x1 = crate::text::measure_text(
235            &crate::text::AnnotatedString::from(&text[line_start..seg_end]),
236            style,
237        )
238        .width
239            + padding_left
240            - pan;
241        let width = x1 - x0;
242        if width > 0.0 {
243            rects.push(cranpose_ui_graphics::Rect {
244                x: x0,
245                y: padding_top + line_idx as f32 * line_height,
246                width,
247                height: line_height,
248            });
249        }
250    }
251    rects
252}
253
254#[derive(Clone)]
255pub(crate) struct TextFieldRefs {
256    pub is_focused: Rc<RefCell<bool>>,
257    pub content_offset: Rc<Cell<f32>>,
258    pub content_y_offset: Rc<Cell<f32>>,
259    pub drag_anchor: Rc<Cell<Option<usize>>>,
260    pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
261    pub last_click_pos: Rc<Cell<Option<(f32, f32)>>>,
262    pub click_count: Rc<Cell<u8>>,
263    pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
264    pub scroll_offset: Rc<Cell<f32>>,
265    pub direct_manipulation: Rc<Cell<bool>>,
266    pub node_origin: Rc<Cell<Point>>,
267    pub line_height: Rc<Cell<f32>>,
268    pub wrap_width: Rc<Cell<Option<f32>>>,
269    pub press_track: MutableState<Option<PointerPressTrack>>,
270    pub gesture_claimed: Rc<Cell<bool>>,
271}
272
273#[derive(Clone, Copy, Debug, PartialEq)]
274pub struct PointerPressTrack {
275    pub start: Point,
276    pub position: Point,
277}
278
279impl TextFieldRefs {
280    pub fn new() -> Self {
281        Self {
282            is_focused: Rc::new(RefCell::new(false)),
283            content_offset: Rc::new(Cell::new(0.0_f32)),
284            content_y_offset: Rc::new(Cell::new(0.0_f32)),
285            drag_anchor: Rc::new(Cell::new(None::<usize>)),
286            last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
287            last_click_pos: Rc::new(Cell::new(None::<(f32, f32)>)),
288            click_count: Rc::new(Cell::new(0_u8)),
289            node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
290            scroll_offset: Rc::new(Cell::new(0.0_f32)),
291            direct_manipulation: Rc::new(Cell::new(false)),
292            node_origin: Rc::new(Cell::new(Point { x: 0.0, y: 0.0 })),
293            line_height: Rc::new(Cell::new(DEFAULT_LINE_HEIGHT)),
294            wrap_width: Rc::new(Cell::new(None::<f32>)),
295            press_track: mutableStateOf(None::<PointerPressTrack>),
296            gesture_claimed: Rc::new(Cell::new(false)),
297        }
298    }
299}
300
301use crate::text::TextStyle;
302
303pub struct TextFieldModifierNode {
304    state: TextFieldState,
305    refs: TextFieldRefs,
306    style: TextStyle,
307    cursor_brush: Brush,
308    selection_brush: Brush,
309    line_limits: TextFieldLineLimits,
310    cached_text: String,
311    cached_selection: TextRange,
312    node_state: NodeState,
313    measured_size: Rc<Cell<Size>>,
314    measured_line_height: Rc<Cell<f32>>,
315    measured_wrap_width: Rc<Cell<Option<f32>>>,
316    cached_handler: Rc<dyn Fn(PointerEvent)>,
317    cached_pan_resolver: TextPanResolver,
318    handle_controller: Option<TextFieldHandleController>,
319    modal_depth: usize,
320}
321
322impl std::fmt::Debug for TextFieldModifierNode {
323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        f.debug_struct("TextFieldModifierNode")
325            .field("text", &self.state.text())
326            .field("style", &self.style)
327            .field("is_focused", &*self.refs.is_focused.borrow())
328            .finish()
329    }
330}
331
332use crate::text_field_handler::TextFieldHandler;
333
334impl TextFieldModifierNode {
335    /// Creates a new text field modifier node.
336    pub fn new(state: TextFieldState, style: TextStyle) -> Self {
337        let value = state.value();
338        let refs = TextFieldRefs::new();
339        let refs_line_height = refs.line_height.clone();
340        let refs_wrap_width = refs.wrap_width.clone();
341        let line_limits = TextFieldLineLimits::default();
342        let cached_handler =
343            Self::create_handler(state, refs.clone(), line_limits, style.clone(), 0);
344        let cached_pan_resolver =
345            Self::create_pan_resolver(state, refs.clone(), line_limits, style.clone());
346
347        Self {
348            state,
349            refs,
350            style,
351            cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
352            selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
353            line_limits,
354            cached_text: value.text,
355            cached_selection: value.selection,
356            node_state: NodeState::new(),
357            measured_size: Rc::new(Cell::new(Size {
358                width: 0.0,
359                height: 0.0,
360            })),
361            measured_line_height: refs_line_height,
362            measured_wrap_width: refs_wrap_width,
363            cached_handler,
364            cached_pan_resolver,
365            handle_controller: None,
366            modal_depth: 0,
367        }
368    }
369
370    /// Creates a node with custom line limits.
371    pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
372        self.line_limits = line_limits;
373        self.rebuild_cached_closures();
374        self
375    }
376
377    fn rebuild_cached_closures(&mut self) {
378        self.cached_handler = Self::create_handler(
379            self.state,
380            self.refs.clone(),
381            self.line_limits,
382            self.style.clone(),
383            self.modal_depth,
384        );
385        self.cached_pan_resolver = Self::create_pan_resolver(
386            self.state,
387            self.refs.clone(),
388            self.line_limits,
389            self.style.clone(),
390        );
391    }
392
393    /// Installs the controller the field publishes live handle metrics to.
394    pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
395        self.handle_controller = Some(controller);
396        self
397    }
398
399    fn create_pan_resolver(
400        state: TextFieldState,
401        refs: TextFieldRefs,
402        line_limits: TextFieldLineLimits,
403        style: TextStyle,
404    ) -> TextPanResolver {
405        Rc::new(move |viewport_width: f32| {
406            if !line_limits.is_single_line() {
407                refs.scroll_offset.set(0.0);
408                return 0.0;
409            }
410            let text = state.text();
411            let pos = state.selection().start.min(text.len());
412            let text_width = crate::text::measure_text(
413                &crate::text::AnnotatedString::from(text.as_str()),
414                &style,
415            )
416            .width;
417            let cursor_x = crate::text::measure_text(
418                &crate::text::AnnotatedString::from(&text[..pos]),
419                &style,
420            )
421            .width;
422            let offset = compute_horizontal_scroll_offset(
423                refs.scroll_offset.get(),
424                cursor_x,
425                text_width,
426                viewport_width,
427            );
428            refs.scroll_offset.set(offset);
429            offset
430        })
431    }
432
433    /// Returns the pan resolver for single-line fields, `None` for multi-line.
434    ///
435    /// Exposed to the modifier slices so the render scene builder can pan the
436    /// text glyphs by the same offset used for the cursor and selection.
437    pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
438        self.line_limits
439            .is_single_line()
440            .then(|| self.cached_pan_resolver.clone())
441    }
442
443    /// Returns the current horizontal scroll (pan) offset in px.
444    pub fn scroll_offset(&self) -> f32 {
445        self.refs.scroll_offset.get()
446    }
447
448    /// Returns the current line limits configuration.
449    pub fn line_limits(&self) -> TextFieldLineLimits {
450        self.line_limits
451    }
452
453    fn create_handler(
454        state: TextFieldState,
455        refs: TextFieldRefs,
456        line_limits: TextFieldLineLimits,
457        style: TextStyle,
458        modal_depth: usize,
459    ) -> Rc<dyn Fn(PointerEvent)> {
460        use crate::{
461            text_selection::{
462                MULTI_TAP_SLOP_PX, MULTI_TAP_TIMEOUT_MS, SelectionGranularity, classify_tap_count,
463                find_line_boundaries, find_paragraph_boundaries, resolve_selection_tap_count,
464                tap_selection_granularity,
465            },
466            word_boundaries::find_word_boundaries,
467        };
468
469        Rc::new(move |event: PointerEvent| {
470            refs.node_origin.set(Point {
471                x: event.global_position.x - event.position.x,
472                y: event.global_position.y - event.position.y,
473            });
474
475            let click_x =
476                (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
477            let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
478
479            match event.kind {
480                PointerEventKind::Down => {
481                    refs.direct_manipulation.set(true);
482                    refs.press_track.set(Some(PointerPressTrack {
483                        start: event.global_position,
484                        position: event.global_position,
485                    }));
486                    refs.gesture_claimed.set(false);
487
488                    let handler = TextFieldHandler::new(
489                        state,
490                        refs.node_id.get(),
491                        line_limits,
492                        crate::text_field_handler::CaretGeometryRefs {
493                            node_origin: refs.node_origin.clone(),
494                            content_offset: refs.content_offset.clone(),
495                            content_y_offset: refs.content_y_offset.clone(),
496                            scroll_offset: refs.scroll_offset.clone(),
497                            style: style.clone(),
498                        },
499                    );
500                    crate::text_field_focus::request_focus(
501                        refs.is_focused.clone(),
502                        handler,
503                        modal_depth,
504                    );
505
506                    let now = web_time::Instant::now();
507                    let text = state.text();
508                    let pos = crate::text::offset_for_position_wrapped(
509                        &text,
510                        &style,
511                        refs.node_id.get(),
512                        refs.wrap_width.get(),
513                        refs.line_height.get(),
514                        click_x,
515                        click_y,
516                    );
517
518                    let previous = refs.last_click_pos.get().and_then(|(px, py)| {
519                        let count = refs.click_count.get();
520                        (count > 0).then_some((count, px, py))
521                    });
522                    let elapsed_ms = refs
523                        .last_click_time
524                        .get()
525                        .map(|last| now.duration_since(last).as_millis())
526                        .unwrap_or(u128::MAX);
527                    let tap_count = classify_tap_count(
528                        previous,
529                        elapsed_ms,
530                        event.position.x,
531                        event.position.y,
532                        MULTI_TAP_TIMEOUT_MS,
533                        MULTI_TAP_SLOP_PX,
534                    );
535
536                    let selection = state.selection();
537                    let tap_in_selection =
538                        !selection.collapsed() && pos >= selection.min() && pos <= selection.max();
539                    let repeat_in_place = refs
540                        .last_click_pos
541                        .get()
542                        .map(|(px, py)| {
543                            let dx = event.position.x - px;
544                            let dy = event.position.y - py;
545                            dx * dx + dy * dy <= MULTI_TAP_SLOP_PX * MULTI_TAP_SLOP_PX
546                        })
547                        .unwrap_or(false);
548                    let effective_count = resolve_selection_tap_count(
549                        tap_count,
550                        refs.click_count.get(),
551                        tap_in_selection,
552                        repeat_in_place,
553                    );
554
555                    match tap_selection_granularity(effective_count) {
556                        SelectionGranularity::Paragraph => {
557                            let (start, end) = find_paragraph_boundaries(&text, pos);
558                            state.edit(|buffer| {
559                                buffer.select(TextRange::new(start, end));
560                            });
561                            refs.drag_anchor.set(Some(start));
562                        }
563                        SelectionGranularity::Line => {
564                            let (line_start, line_end) = find_line_boundaries(&text, pos);
565                            state.edit(|buffer| {
566                                buffer.select(TextRange::new(line_start, line_end));
567                            });
568                            refs.drag_anchor.set(Some(line_start));
569                        }
570                        SelectionGranularity::Word => {
571                            let (word_start, word_end) = find_word_boundaries(&text, pos);
572                            state.edit(|buffer| {
573                                buffer.select(TextRange::new(word_start, word_end));
574                            });
575                            refs.drag_anchor.set(Some(word_start));
576                        }
577                        SelectionGranularity::Caret => {
578                            refs.drag_anchor.set(Some(pos));
579                            state.edit(|buffer| {
580                                buffer.place_cursor_before_char(pos);
581                            });
582                        }
583                    }
584
585                    refs.click_count.set(effective_count);
586                    refs.last_click_time.set(Some(now));
587                    refs.last_click_pos
588                        .set(Some((event.position.x, event.position.y)));
589                    event.consume();
590                }
591                PointerEventKind::Move => {
592                    if let Some(mut track) = refs.press_track.get() {
593                        track.position = event.global_position;
594                        refs.press_track.set(Some(track));
595                        if let Some(node_id) = refs.node_id.get() {
596                            crate::schedule_draw_repass(node_id);
597                        }
598                        crate::request_render_invalidation();
599                    }
600                    if refs.gesture_claimed.get() {
601                        event.consume();
602                        return;
603                    }
604                    if let Some(anchor) = refs.drag_anchor.get()
605                        && *refs.is_focused.borrow()
606                    {
607                        let text = state.text();
608                        let current_pos = crate::text::offset_for_position_wrapped(
609                            &text,
610                            &style,
611                            refs.node_id.get(),
612                            refs.wrap_width.get(),
613                            refs.line_height.get(),
614                            click_x,
615                            click_y,
616                        );
617
618                        state.set_selection(TextRange::new(anchor, current_pos));
619
620                        crate::request_render_invalidation();
621
622                        event.consume();
623                    }
624                }
625                PointerEventKind::Up => {
626                    refs.drag_anchor.set(None);
627                    refs.press_track.set(None);
628                    refs.gesture_claimed.set(false);
629                    if let Some(node_id) = refs.node_id.get() {
630                        crate::schedule_draw_repass(node_id);
631                    }
632                    crate::request_render_invalidation();
633                }
634                PointerEventKind::Cancel => {
635                    refs.press_track.set(None);
636                    refs.gesture_claimed.set(false);
637                    if let Some(node_id) = refs.node_id.get() {
638                        crate::schedule_draw_repass(node_id);
639                    }
640                    crate::request_render_invalidation();
641                }
642                _ => {}
643            }
644        })
645    }
646
647    /// Creates a node with a custom accent: the caret is drawn solid in
648    /// `color` and the selection highlight is derived from it at
649    /// [`crate::widgets::SELECTION_HIGHLIGHT_ALPHA`] — the reference field
650    /// tints caret, handles and highlight from the one accent.
651    pub fn with_cursor_color(mut self, color: Color) -> Self {
652        self.cursor_brush = Brush::solid(color);
653        self.selection_brush = Brush::solid(
654            color.with_alpha(crate::widgets::basic_text_field::SELECTION_HIGHLIGHT_ALPHA),
655        );
656        self
657    }
658
659    /// Sets the focus state.
660    pub fn set_focused(&mut self, focused: bool) {
661        let current = *self.refs.is_focused.borrow();
662        if current != focused {
663            *self.refs.is_focused.borrow_mut() = focused;
664            if !focused {
665                self.refs.direct_manipulation.set(false);
666                self.refs.press_track.set(None);
667                self.refs.gesture_claimed.set(false);
668            }
669        }
670    }
671
672    /// Returns whether the field is focused.
673    pub fn is_focused(&self) -> bool {
674        *self.refs.is_focused.borrow()
675    }
676
677    pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
678        self.refs.node_origin.clone()
679    }
680
681    /// Returns the current text.
682    pub fn text(&self) -> String {
683        self.state.text()
684    }
685
686    pub fn style(&self) -> &TextStyle {
687        &self.style
688    }
689
690    /// Returns the current selection.
691    pub fn selection(&self) -> TextRange {
692        self.state.selection()
693    }
694
695    /// Returns the cursor brush for rendering.
696    pub fn cursor_brush(&self) -> Brush {
697        self.cursor_brush.clone()
698    }
699
700    /// Returns the selection brush for rendering selection highlight.
701    pub fn selection_brush(&self) -> Brush {
702        self.selection_brush.clone()
703    }
704
705    /// Inserts text at the current cursor position (for paste operations).
706    pub fn insert_text(&mut self, text: &str) {
707        self.state.edit(|buffer| {
708            buffer.insert(text);
709        });
710    }
711
712    /// Copies the selected text and returns it (for web copy operation).
713    /// Returns None if no selection.
714    pub fn copy_selection(&self) -> Option<String> {
715        self.state.copy_selection()
716    }
717
718    /// Cuts the selected text: copies and deletes it.
719    /// Returns the cut text, or None if no selection.
720    pub fn cut_selection(&mut self) -> Option<String> {
721        let text = self.copy_selection();
722        if text.is_some() {
723            self.state.edit(|buffer| {
724                buffer.delete(buffer.selection());
725            });
726        }
727        text
728    }
729
730    /// Updates the content offset (padding.left) for accurate click-to-position cursor placement.
731    /// Called from slices collection where padding is known.
732    pub fn set_content_offset(&self, offset: f32) {
733        self.refs.content_offset.set(offset);
734    }
735
736    /// Updates the content Y offset (padding.top) for cursor Y positioning.
737    /// Called from slices collection where padding is known.
738    pub fn set_content_y_offset(&self, offset: f32) {
739        self.refs.content_y_offset.set(offset);
740    }
741
742    fn wrap_width(&self, available_width: f32) -> Option<f32> {
743        (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
744            .then_some(available_width)
745    }
746
747    fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
748        let text = self.state.text();
749        let node_id = self.refs.node_id.get();
750        let annotated = crate::text::AnnotatedString::from(text.as_str());
751        let metrics = match wrap_width {
752            Some(max_width) => crate::text::measure_text_with_options_for_node(
753                node_id,
754                &annotated,
755                &self.style,
756                crate::text::TextLayoutOptions::default(),
757                Some(max_width),
758            ),
759            None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
760        };
761        self.measured_line_height.set(metrics.line_height);
762        Size {
763            width: metrics.width,
764            height: metrics.height,
765        }
766    }
767
768    fn update_cached_state(&mut self) -> bool {
769        let value = self.state.value();
770        let text_changed = value.text != self.cached_text;
771        let selection_changed = value.selection != self.cached_selection;
772
773        if text_changed {
774            self.cached_text = value.text;
775        }
776        if selection_changed {
777            self.cached_selection = value.selection;
778        }
779
780        text_changed || selection_changed
781    }
782}
783
784impl DelegatableNode for TextFieldModifierNode {
785    fn node_state(&self) -> &NodeState {
786        &self.node_state
787    }
788}
789
790impl ModifierNode for TextFieldModifierNode {
791    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
792        self.refs.node_id.set(context.node_id());
793
794        context.invalidate(InvalidationKind::Layout);
795        context.invalidate(InvalidationKind::Draw);
796        context.invalidate(InvalidationKind::Semantics);
797    }
798
799    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
800        Some(self)
801    }
802
803    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
804        Some(self)
805    }
806
807    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
808        Some(self)
809    }
810
811    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
812        Some(self)
813    }
814
815    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
816        Some(self)
817    }
818
819    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
820        Some(self)
821    }
822
823    fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
824        Some(self)
825    }
826
827    fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
828        Some(self)
829    }
830}
831
832impl LayoutModifierNode for TextFieldModifierNode {
833    fn measure(
834        &self,
835        _context: &mut dyn ModifierNodeContext,
836        _measurable: &dyn Measurable,
837        constraints: Constraints,
838    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
839        let wrap_width = self.wrap_width(constraints.max_width);
840        self.measured_wrap_width.set(wrap_width);
841        let text_size = self.measure_text_content(wrap_width);
842
843        let min_height = if text_size.height < 1.0 {
844            DEFAULT_LINE_HEIGHT
845        } else {
846            text_size.height
847        };
848
849        let width = text_size
850            .width
851            .max(constraints.min_width)
852            .min(constraints.max_width);
853        let height = min_height
854            .max(constraints.min_height)
855            .min(constraints.max_height);
856
857        let size = Size { width, height };
858        self.measured_size.set(size);
859
860        let _ = (self.cached_pan_resolver)(size.width);
861
862        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
863    }
864
865    fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
866        self.measure_text_content(None).width
867    }
868
869    fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
870        self.measure_text_content(None).width
871    }
872
873    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
874        self.measure_text_content(self.wrap_width(width))
875            .height
876            .max(DEFAULT_LINE_HEIGHT)
877    }
878
879    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
880        self.measure_text_content(self.wrap_width(width))
881            .height
882            .max(DEFAULT_LINE_HEIGHT)
883    }
884}
885
886fn content_viewport(
887    measured: cranpose_ui_graphics::Size,
888    size: cranpose_foundation::Size,
889    padding_left: f32,
890    padding_top: f32,
891) -> (f32, f32) {
892    let width = if measured.width > 0.0 {
893        measured.width
894    } else {
895        (size.width - padding_left).max(0.0)
896    };
897    let height = if measured.height > 0.0 {
898        measured.height
899    } else {
900        (size.height - padding_top).max(0.0)
901    };
902    (width, height)
903}
904
905impl DrawModifierNode for TextFieldModifierNode {
906    fn draw(&self, _draw_scope: &mut dyn DrawScope) {}
907
908    fn create_draw_closure(
909        &self,
910    ) -> Option<Rc<dyn Fn(&mut cranpose_ui_graphics::DrawScopeDefault)>> {
911        use cranpose_ui_graphics::{DrawPrimitive, DrawScope as _};
912
913        let is_focused = self.refs.is_focused.clone();
914        let state = self.state;
915        let content_offset = self.refs.content_offset.clone();
916        let content_y_offset = self.refs.content_y_offset.clone();
917        let cursor_brush = self.cursor_brush.clone();
918        let style = self.style.clone();
919        let cached_line_height = self.measured_line_height.clone();
920        let measured_size = self.measured_size.clone();
921        let measured_wrap_width = self.measured_wrap_width.clone();
922        let node_id = self.refs.node_id.clone();
923        let pan_resolver = self.cached_pan_resolver.clone();
924        let handle_controller = self.handle_controller.clone();
925        let node_origin = self.refs.node_origin.clone();
926        let direct_manipulation = self.refs.direct_manipulation.clone();
927        let press_track = self.refs.press_track;
928        let gesture_claimed = self.refs.gesture_claimed.clone();
929
930        Some(Rc::new(move |scope| {
931            let size = scope.size();
932            if !*is_focused.borrow() {
933                if let Some(controller) = &handle_controller {
934                    controller.publish(TextFieldHandleMetrics {
935                        focused: false,
936                        direct_manipulation: false,
937                        node_origin: node_origin.get(),
938                        padding_left: 0.0,
939                        padding_top: 0.0,
940                        scroll_offset: 0.0,
941                        line_height: cached_line_height.get(),
942                        glyph_box: crate::text::glyph_line_box(&style, cached_line_height.get()),
943                        wrap_width: measured_wrap_width.get(),
944                    });
945                }
946                return;
947            }
948
949            let mut primitives = Vec::new();
950
951            let text = state.text();
952            let selection = state.selection();
953            let padding_left = content_offset.get();
954            let padding_top = content_y_offset.get();
955            let line_height = cached_line_height.get();
956
957            let (viewport_width, viewport_height) =
958                content_viewport(measured_size.get(), size, padding_left, padding_top);
959            let pan = pan_resolver(viewport_width);
960
961            if let Some(controller) = &handle_controller {
962                controller.adopt_gesture_claim(&gesture_claimed);
963                controller.adopt_press_track(press_track);
964                controller.publish(TextFieldHandleMetrics {
965                    focused: true,
966                    direct_manipulation: direct_manipulation.get(),
967                    node_origin: node_origin.get(),
968                    padding_left,
969                    padding_top,
970                    scroll_offset: pan,
971                    line_height,
972                    glyph_box: crate::text::glyph_line_box(&style, line_height),
973                    wrap_width: measured_wrap_width.get(),
974                });
975            }
976            let clip_bounds = cranpose_ui_graphics::Rect {
977                x: padding_left,
978                y: padding_top,
979                width: viewport_width,
980                height: viewport_height,
981            };
982
983            if let Some(comp_range) = state.composition() {
984                let comp_start = comp_range.min();
985                let comp_end = comp_range.max();
986
987                if comp_start < comp_end && comp_end <= text.len() {
988                    let underline_brush = cranpose_ui_graphics::Brush::solid(
989                        cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
990                    );
991                    let underline_height: f32 = 2.0;
992
993                    for line_rect in range_visual_line_rects(
994                        &text,
995                        &style,
996                        node_id.get(),
997                        measured_wrap_width.get(),
998                        padding_left,
999                        padding_top,
1000                        pan,
1001                        line_height,
1002                        comp_start,
1003                        comp_end,
1004                    ) {
1005                        let underline_rect = cranpose_ui_graphics::Rect {
1006                            x: line_rect.x,
1007                            y: line_rect.y + line_height - underline_height,
1008                            width: line_rect.width,
1009                            height: underline_height,
1010                        };
1011                        if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1012                            primitives.push(DrawPrimitive::Rect {
1013                                rect: clipped,
1014                                brush: underline_brush.clone(),
1015                                stroke: None,
1016                            });
1017                        }
1018                    }
1019                }
1020            }
1021
1022            if selection.collapsed() && crate::cursor_animation::is_cursor_visible() {
1023                let pos = selection.start.min(text.len());
1024                let (line_index, line_start) = caret_visual_line_for_offset(
1025                    &text,
1026                    &style,
1027                    node_id.get(),
1028                    measured_wrap_width.get(),
1029                    pos,
1030                    crate::text_selection::LineAffinity::Upstream,
1031                );
1032                let cursor_x = crate::text::measure_text(
1033                    &crate::text::AnnotatedString::from(&text[line_start..pos]),
1034                    &style,
1035                )
1036                .width
1037                    + padding_left
1038                    - pan;
1039                let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1040                let cursor_y = padding_top + line_index as f32 * line_height + box_off;
1041
1042                let cursor_rect = cranpose_ui_graphics::Rect {
1043                    x: cursor_x,
1044                    y: cursor_y,
1045                    width: CURSOR_WIDTH,
1046                    height: box_h,
1047                };
1048
1049                if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1050                    primitives.push(DrawPrimitive::Rect {
1051                        rect: clipped,
1052                        brush: cursor_brush.clone(),
1053                        stroke: None,
1054                    });
1055                }
1056            }
1057
1058            scope.push_recorded(primitives);
1059        }))
1060    }
1061
1062    fn create_behind_draw_closure(
1063        &self,
1064    ) -> Option<Rc<dyn Fn(&mut cranpose_ui_graphics::DrawScopeDefault)>> {
1065        use cranpose_ui_graphics::{DrawPrimitive, DrawScope as _};
1066
1067        let is_focused = self.refs.is_focused.clone();
1068        let state = self.state;
1069        let content_offset = self.refs.content_offset.clone();
1070        let content_y_offset = self.refs.content_y_offset.clone();
1071        let selection_brush = self.selection_brush.clone();
1072        let style = self.style.clone();
1073        let cached_line_height = self.measured_line_height.clone();
1074        let measured_size = self.measured_size.clone();
1075        let measured_wrap_width = self.measured_wrap_width.clone();
1076        let node_id = self.refs.node_id.clone();
1077        let pan_resolver = self.cached_pan_resolver.clone();
1078
1079        Some(Rc::new(move |scope| {
1080            let size = scope.size();
1081            if !*is_focused.borrow() {
1082                return;
1083            }
1084            let selection = state.selection();
1085            if selection.collapsed() {
1086                return;
1087            }
1088            let text = state.text();
1089            let padding_left = content_offset.get();
1090            let padding_top = content_y_offset.get();
1091            let line_height = cached_line_height.get();
1092            let (viewport_width, viewport_height) =
1093                content_viewport(measured_size.get(), size, padding_left, padding_top);
1094            let pan = pan_resolver(viewport_width);
1095            let clip_bounds = cranpose_ui_graphics::Rect {
1096                x: padding_left,
1097                y: padding_top,
1098                width: viewport_width,
1099                height: viewport_height,
1100            };
1101
1102            let mut primitives = Vec::new();
1103            let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1104            for sel_rect in range_visual_line_rects(
1105                &text,
1106                &style,
1107                node_id.get(),
1108                measured_wrap_width.get(),
1109                padding_left,
1110                padding_top,
1111                pan,
1112                line_height,
1113                selection.min(),
1114                selection.max(),
1115            ) {
1116                let sel_rect = cranpose_ui_graphics::Rect {
1117                    y: sel_rect.y + box_off,
1118                    height: box_h,
1119                    ..sel_rect
1120                };
1121                if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1122                    primitives.push(DrawPrimitive::Rect {
1123                        rect: clipped,
1124                        brush: selection_brush.clone(),
1125                        stroke: None,
1126                    });
1127                }
1128            }
1129            scope.push_recorded(primitives);
1130        }))
1131    }
1132}
1133
1134impl SemanticsNode for TextFieldModifierNode {
1135    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1136        let text = self.state.text();
1137        config.content_description = Some(text);
1138        config.is_editable_text = true;
1139        config.text_selection = Some(self.state.selection());
1140    }
1141}
1142
1143impl PointerInputNode for TextFieldModifierNode {
1144    fn on_pointer_event(
1145        &mut self,
1146        _context: &mut dyn ModifierNodeContext,
1147        _event: &PointerEvent,
1148    ) -> bool {
1149        false
1150    }
1151
1152    fn hit_test(&self, x: f32, y: f32) -> bool {
1153        let size = self.measured_size.get();
1154        x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1155    }
1156
1157    fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1158        Some(self.cached_handler.clone())
1159    }
1160}
1161
1162/// Element that creates and updates `TextFieldModifierNode` instances.
1163///
1164/// This follows the modifier element pattern where the element is responsible for:
1165/// - Creating new nodes (via `create`)
1166/// - Updating existing nodes when properties change (via `update`)
1167/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
1168#[derive(Clone)]
1169pub struct TextFieldElement {
1170    state: TextFieldState,
1171    style: TextStyle,
1172    cursor_color: Color,
1173    line_limits: TextFieldLineLimits,
1174    handle_controller: Option<TextFieldHandleController>,
1175    modal_depth: usize,
1176}
1177
1178impl TextFieldElement {
1179    /// Creates a new text field element.
1180    pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1181        Self {
1182            state,
1183            style,
1184            cursor_color: DEFAULT_CURSOR_COLOR,
1185            line_limits: TextFieldLineLimits::default(),
1186            handle_controller: None,
1187            modal_depth: 0,
1188        }
1189    }
1190
1191    /// Creates an element with custom cursor color.
1192    pub fn with_cursor_color(mut self, color: Color) -> Self {
1193        self.cursor_color = color;
1194        self
1195    }
1196
1197    /// Creates an element with custom line limits.
1198    pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1199        self.line_limits = line_limits;
1200        self
1201    }
1202
1203    /// Installs the finger-handle metrics channel shared with the composable.
1204    pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1205        self.handle_controller = Some(controller);
1206        self
1207    }
1208
1209    /// Sets the modal depth this field was composed at (see
1210    /// [`crate::modal::local_modal_depth`]).
1211    pub fn with_modal_depth(mut self, depth: usize) -> Self {
1212        self.modal_depth = depth;
1213        self
1214    }
1215}
1216
1217impl std::fmt::Debug for TextFieldElement {
1218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1219        f.debug_struct("TextFieldElement")
1220            .field("text", &self.state.text())
1221            .field("style", &self.style)
1222            .field("cursor_color", &self.cursor_color)
1223            .finish()
1224    }
1225}
1226
1227impl Hash for TextFieldElement {
1228    fn hash<H: Hasher>(&self, state: &mut H) {
1229        self.state.id().hash(state);
1230        self.cursor_color.0.to_bits().hash(state);
1231        self.cursor_color.1.to_bits().hash(state);
1232        self.cursor_color.2.to_bits().hash(state);
1233        self.cursor_color.3.to_bits().hash(state);
1234        self.style.render_hash().hash(state);
1235        self.line_limits.hash(state);
1236        self.modal_depth.hash(state);
1237    }
1238}
1239
1240impl PartialEq for TextFieldElement {
1241    fn eq(&self, other: &Self) -> bool {
1242        self.state == other.state
1243            && self.style == other.style
1244            && self.cursor_color == other.cursor_color
1245            && self.line_limits == other.line_limits
1246            && self.modal_depth == other.modal_depth
1247    }
1248}
1249
1250impl Eq for TextFieldElement {}
1251
1252impl ModifierNodeElement for TextFieldElement {
1253    type Node = TextFieldModifierNode;
1254
1255    fn create(&self) -> Self::Node {
1256        let mut node = TextFieldModifierNode::new(self.state, self.style.clone())
1257            .with_cursor_color(self.cursor_color)
1258            .with_line_limits(self.line_limits);
1259        node.modal_depth = self.modal_depth;
1260        if let Some(controller) = self.handle_controller.clone() {
1261            node = node.with_handle_controller(controller);
1262        }
1263        node.rebuild_cached_closures();
1264        node
1265    }
1266
1267    fn update(&self, node: &mut Self::Node) {
1268        node.state = self.state;
1269        node.style = self.style.clone();
1270        node.cursor_brush = Brush::solid(self.cursor_color);
1271        node.line_limits = self.line_limits;
1272        node.handle_controller = self.handle_controller.clone();
1273        node.modal_depth = self.modal_depth;
1274        node.rebuild_cached_closures();
1275
1276        if node.update_cached_state() {}
1277    }
1278
1279    fn capabilities(&self) -> NodeCapabilities {
1280        NodeCapabilities::LAYOUT
1281            | NodeCapabilities::DRAW
1282            | NodeCapabilities::SEMANTICS
1283            | NodeCapabilities::POINTER_INPUT
1284    }
1285
1286    fn always_update(&self) -> bool {
1287        true
1288    }
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293    use std::sync::Arc;
1294
1295    use cranpose_core::{DefaultScheduler, Runtime};
1296
1297    use super::*;
1298    use crate::text::TextStyle;
1299
1300    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1301        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1302        f()
1303    }
1304
1305    #[test]
1306    fn text_field_node_creation() {
1307        let _app_context = crate::render_state::app_context_test_scope();
1308        with_test_runtime(|| {
1309            let state = TextFieldState::new("Hello");
1310            let node = TextFieldModifierNode::new(state, TextStyle::default());
1311            assert_eq!(node.text(), "Hello");
1312            assert!(!node.is_focused());
1313        });
1314    }
1315
1316    #[test]
1317    fn selection_rects_follow_wrapped_visual_lines() {
1318        let _app_context = crate::render_state::app_context_test_scope();
1319        let text = "aaaaa\nbb";
1320        let style = TextStyle::default();
1321        let line_height = 10.0_f32;
1322
1323        let rects = range_visual_line_rects(
1324            text,
1325            &style,
1326            None,
1327            Some(30.0),
1328            0.0,
1329            0.0,
1330            0.0,
1331            line_height,
1332            6,
1333            8,
1334        );
1335        assert_eq!(rects.len(), 1, "one visual line touched, got {rects:?}");
1336        assert_eq!(
1337            rects[0].y,
1338            2.0 * line_height,
1339            "highlight must land on visual line 2, not logical line 1"
1340        );
1341        assert!(rects[0].width > 0.0);
1342
1343        let spanning = range_visual_line_rects(
1344            text,
1345            &style,
1346            None,
1347            Some(30.0),
1348            0.0,
1349            0.0,
1350            0.0,
1351            line_height,
1352            0,
1353            5,
1354        );
1355        assert_eq!(spanning.len(), 2, "wrapped line spans two visual rows");
1356        assert_eq!(spanning[0].y, 0.0);
1357        assert_eq!(spanning[1].y, line_height);
1358    }
1359
1360    #[test]
1361    fn tap_resolves_offset_on_wrapped_visual_line() {
1362        let _app_context = crate::render_state::app_context_test_scope();
1363        let text = "aaaaa\nbb";
1364        let style = TextStyle::default();
1365        let line_height = 10.0_f32;
1366
1367        let off = crate::text::offset_for_position_wrapped(
1368            text,
1369            &style,
1370            None,
1371            Some(30.0),
1372            line_height,
1373            8.0,
1374            22.0,
1375        );
1376        assert!(
1377            (6..=8).contains(&off),
1378            "tap on visual line 'bb' resolved to {off}, expected 6..=8"
1379        );
1380
1381        let off1 = crate::text::offset_for_position_wrapped(
1382            text,
1383            &style,
1384            None,
1385            Some(30.0),
1386            line_height,
1387            4.0,
1388            12.0,
1389        );
1390        assert!(
1391            (3..=5).contains(&off1),
1392            "tap on wrapped 'aa' resolved to {off1}, expected 3..=5"
1393        );
1394
1395        let off2 = crate::text::offset_for_position_wrapped(
1396            "hello",
1397            &style,
1398            None,
1399            None,
1400            line_height,
1401            0.0,
1402            0.0,
1403        );
1404        assert_eq!(off2, 0);
1405    }
1406
1407    #[test]
1408    fn text_field_node_focus() {
1409        let _app_context = crate::render_state::app_context_test_scope();
1410        with_test_runtime(|| {
1411            let state = TextFieldState::new("Test");
1412            let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1413            assert!(!node.is_focused());
1414
1415            node.set_focused(true);
1416            assert!(node.is_focused());
1417
1418            node.set_focused(false);
1419            assert!(!node.is_focused());
1420        });
1421    }
1422
1423    #[test]
1424    fn text_field_element_creates_node() {
1425        let _app_context = crate::render_state::app_context_test_scope();
1426        with_test_runtime(|| {
1427            let state = TextFieldState::new("Hello World");
1428            let element = TextFieldElement::new(state, TextStyle::default());
1429
1430            let node = element.create();
1431            assert_eq!(node.text(), "Hello World");
1432        });
1433    }
1434
1435    #[test]
1436    fn every_primary_pointer_source_publishes_direct_manipulation_metrics() {
1437        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1438        use cranpose_ui_graphics::Point;
1439
1440        let _app_context = crate::render_state::app_context_test_scope();
1441        with_test_runtime(|| {
1442            let state = TextFieldState::new("hello world");
1443            let controller = TextFieldHandleController::new();
1444            let mut node = TextFieldModifierNode::new(state, TextStyle::default())
1445                .with_handle_controller(controller.clone());
1446            node.measured_size.set(Size {
1447                width: 120.0,
1448                height: 20.0,
1449            });
1450
1451            let handler = node
1452                .pointer_input_handler()
1453                .expect("field exposes a pointer handler");
1454            let draw = node
1455                .create_draw_closure()
1456                .expect("field exposes a draw closure");
1457            let at = Point { x: 12.0, y: 8.0 };
1458            let size = Size {
1459                width: 120.0,
1460                height: 20.0,
1461            };
1462            let run_draw = || {
1463                let mut scope = crate::draw::command_draw_scope(size);
1464                draw(&mut scope);
1465            };
1466
1467            node.set_focused(true);
1468            run_draw();
1469            let keyboard_metrics = controller
1470                .metrics()
1471                .expect("focused field publishes handle metrics");
1472            assert!(!keyboard_metrics.direct_manipulation);
1473
1474            handler(
1475                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1476            );
1477            run_draw();
1478            let metrics = controller
1479                .metrics()
1480                .expect("focused field publishes handle metrics");
1481            assert!(metrics.focused, "a tap focuses the field");
1482            assert!(
1483                metrics.direct_manipulation,
1484                "a touch tap must expose direct-manipulation handles"
1485            );
1486            assert!(
1487                controller.press().is_some(),
1488                "touch must publish the live press"
1489            );
1490
1491            handler(
1492                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1493            );
1494            run_draw();
1495            let metrics = controller
1496                .metrics()
1497                .expect("focused field publishes handle metrics");
1498            assert!(
1499                metrics.direct_manipulation,
1500                "a mouse tap must expose the same direct-manipulation handles"
1501            );
1502            assert!(
1503                controller.press().is_some(),
1504                "mouse must publish the live press"
1505            );
1506
1507            handler(
1508                PointerEvent::new(PointerEventKind::Down, at, at)
1509                    .with_source(PointerSource::Stylus),
1510            );
1511            run_draw();
1512            let metrics = controller
1513                .metrics()
1514                .expect("focused field publishes handle metrics");
1515            assert!(
1516                metrics.direct_manipulation,
1517                "a stylus contact must expose the same direct-manipulation handles"
1518            );
1519            assert!(
1520                controller.press().is_some(),
1521                "stylus must publish the live press"
1522            );
1523
1524            crate::text_field_focus::clear_focus();
1525        });
1526    }
1527
1528    #[test]
1529    fn double_tap_selects_the_word_under_the_finger() {
1530        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1531        use cranpose_ui_graphics::Point;
1532
1533        let _app_context = crate::render_state::app_context_test_scope();
1534        with_test_runtime(|| {
1535            let state = TextFieldState::new("hello world");
1536            let node = TextFieldModifierNode::new(state, TextStyle::default());
1537            node.measured_size.set(Size {
1538                width: 200.0,
1539                height: 20.0,
1540            });
1541            let handler = node
1542                .pointer_input_handler()
1543                .expect("field exposes a pointer handler");
1544
1545            let at = Point { x: 2.0, y: 8.0 };
1546            handler(
1547                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1548            );
1549            handler(
1550                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1551            );
1552
1553            let selection = state.selection();
1554            assert!(
1555                !selection.collapsed(),
1556                "a double tap must produce a (word) selection, got {selection:?}"
1557            );
1558            let selected = &state.text()[selection.min()..selection.max()];
1559            assert_eq!(
1560                selected, "hello",
1561                "double tap should select the whole word under the finger"
1562            );
1563
1564            crate::text_field_focus::clear_focus();
1565        });
1566    }
1567
1568    #[test]
1569    fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1570        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1571        use cranpose_ui_graphics::Point;
1572
1573        let _app_context = crate::render_state::app_context_test_scope();
1574        with_test_runtime(|| {
1575            let text = "alpha beta\ngamma delta\n\nsecond para";
1576            let state = TextFieldState::new(text);
1577            let node = TextFieldModifierNode::new(state, TextStyle::default()).with_line_limits(
1578                TextFieldLineLimits::MultiLine {
1579                    min_lines: 1,
1580                    max_lines: usize::MAX,
1581                },
1582            );
1583            node.measured_size.set(Size {
1584                width: 400.0,
1585                height: 80.0,
1586            });
1587            let handler = node
1588                .pointer_input_handler()
1589                .expect("field exposes a pointer handler");
1590
1591            let at = Point { x: 2.0, y: 4.0 };
1592            let tap = || {
1593                handler(
1594                    PointerEvent::new(PointerEventKind::Down, at, at)
1595                        .with_source(PointerSource::Touch),
1596                );
1597            };
1598            let selected = |state: &TextFieldState| {
1599                let s = state.selection();
1600                state.text()[s.min()..s.max()].to_string()
1601            };
1602
1603            tap();
1604            assert!(state.selection().collapsed(), "first tap places the caret");
1605            tap();
1606            assert_eq!(selected(&state), "alpha", "double tap selects the word");
1607            tap();
1608            assert_eq!(
1609                selected(&state),
1610                "alpha beta",
1611                "triple tap selects the line"
1612            );
1613            tap();
1614            assert_eq!(
1615                selected(&state),
1616                "alpha beta\ngamma delta",
1617                "fourth tap grows to the paragraph"
1618            );
1619            tap();
1620            assert_eq!(
1621                selected(&state),
1622                "alpha",
1623                "fifth tap cycles back to the word"
1624            );
1625
1626            crate::text_field_focus::clear_focus();
1627        });
1628    }
1629
1630    #[test]
1631    fn single_tap_inside_selection_selects_the_word() {
1632        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1633        use cranpose_ui_graphics::Point;
1634
1635        let _app_context = crate::render_state::app_context_test_scope();
1636        with_test_runtime(|| {
1637            let state = TextFieldState::new("hello world");
1638            let node = TextFieldModifierNode::new(state, TextStyle::default());
1639            node.measured_size.set(Size {
1640                width: 200.0,
1641                height: 20.0,
1642            });
1643            let handler = node
1644                .pointer_input_handler()
1645                .expect("field exposes a pointer handler");
1646
1647            state.edit(|buffer| buffer.select(TextRange::new(0, 11)));
1648            assert!(!state.selection().collapsed());
1649
1650            let at = Point { x: 2.0, y: 8.0 };
1651            handler(
1652                PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1653            );
1654
1655            let selection = state.selection();
1656            assert!(
1657                !selection.collapsed(),
1658                "a tap inside a selection must not collapse it, got {selection:?}"
1659            );
1660            assert_eq!(
1661                &state.text()[selection.min()..selection.max()],
1662                "hello",
1663                "a tap inside a selection re-selects the word under the finger"
1664            );
1665
1666            crate::text_field_focus::clear_focus();
1667        });
1668    }
1669
1670    #[test]
1671    fn slow_taps_inside_selection_cycle_word_line_paragraph_by_location() {
1672        use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1673        use cranpose_ui_graphics::Point;
1674
1675        let _app_context = crate::render_state::app_context_test_scope();
1676        with_test_runtime(|| {
1677            let text = "alpha beta\ngamma delta\n\nsecond para";
1678            let state = TextFieldState::new(text);
1679            let node = TextFieldModifierNode::new(state, TextStyle::default()).with_line_limits(
1680                TextFieldLineLimits::MultiLine {
1681                    min_lines: 1,
1682                    max_lines: usize::MAX,
1683                },
1684            );
1685            node.measured_size.set(Size {
1686                width: 400.0,
1687                height: 80.0,
1688            });
1689            let handler = node
1690                .pointer_input_handler()
1691                .expect("field exposes a pointer handler");
1692
1693            state.edit(|buffer| buffer.select(TextRange::new(0, text.len())));
1694
1695            let at = Point { x: 2.0, y: 4.0 };
1696            let selected = |state: &TextFieldState| {
1697                let s = state.selection();
1698                state.text()[s.min()..s.max()].to_string()
1699            };
1700            let slow_tap = || {
1701                node.refs.last_click_time.set(None);
1702                handler(
1703                    PointerEvent::new(PointerEventKind::Down, at, at)
1704                        .with_source(PointerSource::Touch),
1705                );
1706            };
1707
1708            slow_tap();
1709            assert_eq!(
1710                selected(&state),
1711                "alpha",
1712                "tap inside selection grabs the word"
1713            );
1714            slow_tap();
1715            assert_eq!(
1716                selected(&state),
1717                "alpha beta",
1718                "same-spot tap grows to the line even after the timeout"
1719            );
1720            slow_tap();
1721            assert_eq!(
1722                selected(&state),
1723                "alpha beta\ngamma delta",
1724                "same-spot tap grows to the paragraph"
1725            );
1726            slow_tap();
1727            assert_eq!(
1728                selected(&state),
1729                "alpha",
1730                "same-spot tap cycles back to the word"
1731            );
1732
1733            crate::text_field_focus::clear_focus();
1734        });
1735    }
1736
1737    #[test]
1738    fn text_field_element_equality() {
1739        let _app_context = crate::render_state::app_context_test_scope();
1740        with_test_runtime(|| {
1741            let state1 = TextFieldState::new("Hello");
1742            let state2 = TextFieldState::new("Hello");
1743
1744            let elem1 = TextFieldElement::new(state1, TextStyle::default());
1745            let elem2 = TextFieldElement::new(state1, TextStyle::default());
1746            let elem3 = TextFieldElement::new(state2, TextStyle::default());
1747
1748            assert_eq!(elem1, elem2, "Same state should be equal");
1749            assert_ne!(elem1, elem3, "Different states should not be equal");
1750        });
1751    }
1752
1753    #[test]
1754    fn text_field_element_update_refreshes_existing_node_style() {
1755        let _app_context = crate::render_state::app_context_test_scope();
1756        with_test_runtime(|| {
1757            let state = TextFieldState::new("themed text");
1758            let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
1759                color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
1760                ..crate::text::SpanStyle::default()
1761            });
1762            let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
1763                color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
1764                ..crate::text::SpanStyle::default()
1765            });
1766            let initial = TextFieldElement::new(state, dark_style);
1767            let updated = TextFieldElement::new(state, light_style.clone());
1768            let mut node = initial.create();
1769
1770            updated.update(&mut node);
1771
1772            assert_eq!(node.text(), "themed text");
1773            assert_eq!(node.style(), &light_style);
1774        });
1775    }
1776
1777    #[test]
1778    fn multiline_field_measures_wrapped_height() {
1779        let _app_context = crate::render_state::app_context_test_scope();
1780        with_test_runtime(|| {
1781            let long = "abcd ".repeat(40);
1782            let state = TextFieldState::new(&long);
1783            let node = TextFieldModifierNode::new(state, TextStyle::default());
1784            assert!(
1785                !node.line_limits().is_single_line(),
1786                "default fields are multi-line"
1787            );
1788
1789            let natural = node.measure_text_content(None);
1790            let wrapped = node.measure_text_content(node.wrap_width(20.0));
1791
1792            assert!(
1793                wrapped.height > natural.height,
1794                "wrapped multi-line height {} must exceed the single-line height {}",
1795                wrapped.height,
1796                natural.height
1797            );
1798        });
1799    }
1800
1801    #[test]
1802    fn single_line_field_never_wraps() {
1803        let _app_context = crate::render_state::app_context_test_scope();
1804        with_test_runtime(|| {
1805            let state = TextFieldState::new("abcd ".repeat(40));
1806            let node = TextFieldModifierNode::new(state, TextStyle::default())
1807                .with_line_limits(TextFieldLineLimits::SingleLine);
1808            assert_eq!(
1809                node.wrap_width(20.0),
1810                None,
1811                "single-line fields must not wrap"
1812            );
1813        });
1814    }
1815
1816    #[test]
1817    fn test_cursor_x_position_calculation() {
1818        let _app_context = crate::render_state::app_context_test_scope();
1819        with_test_runtime(|| {
1820            let style = crate::text::TextStyle::default();
1821
1822            let empty_width =
1823                crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
1824            assert!(
1825                empty_width.abs() < 0.1,
1826                "Empty text should have 0 width, got {}",
1827                empty_width
1828            );
1829
1830            let hi_width =
1831                crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
1832            assert!(
1833                hi_width > 0.0,
1834                "Text 'Hi' should have positive width: {}",
1835                hi_width
1836            );
1837
1838            let h_width =
1839                crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
1840            assert!(h_width > 0.0, "Text 'H' should have positive width");
1841            assert!(
1842                h_width < hi_width,
1843                "'H' width {} should be less than 'Hi' width {}",
1844                h_width,
1845                hi_width
1846            );
1847
1848            let state = TextFieldState::new("Hi");
1849            assert_eq!(
1850                state.selection().start,
1851                2,
1852                "Cursor should be at position 2 (end of 'Hi')"
1853            );
1854
1855            let text = state.text();
1856            let cursor_pos = state.selection().start;
1857            let text_before_cursor = &text[..cursor_pos.min(text.len())];
1858            assert_eq!(text_before_cursor, "Hi");
1859
1860            let cursor_x = crate::text::measure_text(
1861                &crate::text::AnnotatedString::from(text_before_cursor),
1862                &style,
1863            )
1864            .width;
1865            assert!(
1866                (cursor_x - hi_width).abs() < 0.1,
1867                "Cursor x {} should equal 'Hi' width {}",
1868                cursor_x,
1869                hi_width
1870            );
1871        });
1872    }
1873
1874    #[test]
1875    fn test_focused_node_creates_cursor() {
1876        let _app_context = crate::render_state::app_context_test_scope();
1877        with_test_runtime(|| {
1878            let state = TextFieldState::new("Test");
1879            let element = TextFieldElement::new(state, TextStyle::default());
1880            let node = element.create();
1881
1882            assert!(!node.is_focused());
1883
1884            *node.refs.is_focused.borrow_mut() = true;
1885            assert!(node.is_focused());
1886
1887            assert_eq!(node.text(), "Test");
1888
1889            assert_eq!(node.selection().start, 4);
1890        });
1891    }
1892}