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