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