Skip to main content

cranpose_ui/widgets/
basic_text_field.rs

1//! BasicTextField widget for editable text input.
2//!
3//! This module provides the `BasicTextField` composable following Jetpack Compose's
4//! `BasicTextField` pattern from `compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt`.
5
6#![allow(non_snake_case)]
7
8use crate::bring_into_view::local_bring_into_view_responder;
9use crate::clipboard_session::{
10    clipboard_can_paste, clipboard_paste_into_focus, clipboard_write_text,
11};
12use crate::composable;
13use crate::layout::policies::EmptyMeasurePolicy;
14use crate::modifier::Modifier;
15use crate::safe_area::local_ime_insets;
16use crate::text::{measure_text, AnnotatedString, TextStyle};
17use crate::text_field_focus::{dispatch_copy, dispatch_cut, dispatch_select_all};
18use crate::text_field_modifier_node::{
19    TextFieldElement, TextFieldHandleController, TextFieldHandleMetrics,
20};
21use crate::text_selection::{
22    selection_after_handle_drag, HandleGrabOffset, HandleKind, LineAffinity, HANDLE_RADIUS,
23};
24use crate::widgets::{
25    loupe_target_for_drag, CaretActionMenu, Layout, SelectionHandle, SelectionLoupe,
26    TextSelectionMenu,
27};
28use cranpose_core::{mutableStateOf, remember, MutableState, NodeId, SideEffect};
29use cranpose_foundation::modifier_element;
30use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
31use cranpose_ui_graphics::{Color, Point, Rect};
32use std::cell::{Cell, RefCell};
33use std::rc::{Rc, Weak};
34
35/// Alpha of the selection highlight relative to the field's accent
36/// ([`TextFieldOptions::cursor_color`]): the reference highlight is the tint
37/// at ~0.32 opacity, while the caret and both selection handles carry it
38/// solid — one accent drives all three.
39pub const SELECTION_HIGHLIGHT_ALPHA: f32 = 0.32;
40
41/// Hold duration before a stationary touch press on the text claims the
42/// gesture (word-select + menu while the finger is still down).
43const TEXT_LONG_PRESS_MS: u64 = 500;
44/// Travel beyond this (dp) before the hold elapses is a drag, not a
45/// long-press.
46const TEXT_LONG_PRESS_SLOP: f32 = 12.0;
47
48/// Frame-clock watcher for the long-press → slide-to-menu gesture: armed by
49/// the composition when the field node publishes a fresh touch press, it
50/// claims the gesture after the hold threshold (selecting the word under
51/// the press; the range-change side effect opens the menu). The composition
52/// slot holds the only strong reference — dropping it (press ended, field
53/// recomposed away) cancels the pending frame callback.
54struct LongPressWatcher {
55    controller: TextFieldHandleController,
56    state: TextFieldState,
57    style: TextStyle,
58    start: Point,
59    start_nanos: Cell<Option<u64>>,
60    registration: RefCell<Option<cranpose_core::internal::FrameCallbackRegistration>>,
61    frame_clock: cranpose_core::internal::FrameClock,
62}
63
64impl LongPressWatcher {
65    fn arm(self: &Rc<Self>) {
66        let weak: Weak<LongPressWatcher> = Rc::downgrade(self);
67        let registration = self.frame_clock.with_frame_nanos(move |now| {
68            let Some(watcher) = weak.upgrade() else {
69                return;
70            };
71            watcher.tick(now);
72        });
73        *self.registration.borrow_mut() = Some(registration);
74        // The watcher only advances while frames run; keep them coming for
75        // the (otherwise idle) stationary hold.
76        crate::request_render_invalidation();
77    }
78
79    fn tick(self: Rc<Self>, now: u64) {
80        self.registration.borrow_mut().take();
81        let Some(press) = self.controller.press() else {
82            return; // press ended — the composition slot will drop us
83        };
84        let moved = (press.position.x - self.start.x)
85            .abs()
86            .max((press.position.y - self.start.y).abs());
87        if (press.start.x - self.start.x).abs() > 0.5
88            || (press.start.y - self.start.y).abs() > 0.5
89            || moved > TEXT_LONG_PRESS_SLOP
90        {
91            return; // a different/dragging gesture
92        }
93        let start = match self.start_nanos.get() {
94            Some(value) => value,
95            None => {
96                self.start_nanos.set(Some(now));
97                now
98            }
99        };
100        if now.saturating_sub(start) < TEXT_LONG_PRESS_MS * 1_000_000 {
101            self.arm();
102            return;
103        }
104        // Hold elapsed: claim the gesture and select the word under the
105        // press. The selection-range side effect opens the menu; the node
106        // stops drag-selecting under the claim.
107        self.controller.claim_gesture();
108        let Some(metrics) = self.controller.metrics() else {
109            return;
110        };
111        let text = self.state.text();
112        let offset = window_pos_to_offset(&text, &self.style, &metrics, self.start, 0.0);
113        let (word_start, word_end) = crate::word_boundaries::find_word_boundaries(&text, offset);
114        self.state.edit(|buffer| {
115            buffer.select(TextRange::new(word_start, word_end));
116        });
117        crate::request_render_invalidation();
118    }
119}
120
121/// Window-space position where a handle's tip should sit for the caret/selection
122/// endpoint at byte `offset`: the bottom of that offset's visual line.
123/// `affinity` decides the line at a shared soft-wrap boundary: selection ENDS,
124/// the cursor handle and the loupe anchor upstream (the line the finger rides),
125/// the selection START anchors downstream (the first highlighted glyph).
126fn handle_tip_window_pos(
127    text: &str,
128    style: &TextStyle,
129    metrics: &TextFieldHandleMetrics,
130    offset: usize,
131    affinity: LineAffinity,
132) -> Point {
133    let offset = offset.min(text.len());
134    // Resolve the caret's VISUAL (wrapped) line so the handle tip anchors on the
135    // same glyph as the drawn caret (the field wraps long lines; counting only
136    // logical `\n` lines would place the handle on the wrong line, far right).
137    let (line_index, line_start) = crate::text_field_modifier_node::caret_visual_line_for_offset(
138        text,
139        style,
140        None,
141        metrics.wrap_width,
142        offset,
143        affinity,
144    );
145    let caret_x = measure_text(&AnnotatedString::from(&text[line_start..offset]), style).width;
146    Point {
147        x: metrics.node_origin.x + metrics.padding_left + caret_x - metrics.scroll_offset,
148        // The tip rides the TIGHT glyph box bottom, not the slot bottom —
149        // handles (and the caret) anchor on the glyphs like the reference.
150        y: metrics.node_origin.y
151            + metrics.padding_top
152            + line_index as f32 * metrics.line_height
153            + metrics.glyph_box.0
154            + metrics.glyph_box.1,
155    }
156}
157
158/// Maps a window-space drag position back to the nearest text byte offset in
159/// the field. `y_bias` is the finger-to-line offset captured when the handle
160/// was grabbed (`grab line bottom − finger y`): adding it back keeps the drag
161/// targeting the line the finger means, whether the grab was on the line
162/// itself (stem/edge) or on the dot hanging outside it — the reference drags
163/// preserve the initial finger-to-line relationship.
164fn window_pos_to_offset(
165    text: &str,
166    style: &TextStyle,
167    metrics: &TextFieldHandleMetrics,
168    window_pos: Point,
169    y_bias: f32,
170) -> usize {
171    let local_x = (window_pos.x - metrics.node_origin.x - metrics.padding_left
172        + metrics.scroll_offset)
173        .max(0.0);
174    // The biased y lands on the grabbed line's bottom; sample half a line up
175    // to hit the line's middle.
176    let local_y = (window_pos.y + y_bias
177        - 0.5 * metrics.line_height
178        - metrics.node_origin.y
179        - metrics.padding_top)
180        .max(0.0);
181    // Resolve the VISUAL (wrapped) line the same way the drawn caret and
182    // `handle_tip_window_pos` do. The plain measurer maps `y` through logical
183    // `\n` lines only, so on wrapped text a handle drag lands on the wrong line
184    // (an offset that grows with each wrapped line above the finger).
185    crate::text::offset_for_position_wrapped(
186        text,
187        style,
188        None,
189        metrics.wrap_width,
190        metrics.line_height,
191        local_x,
192        local_y,
193    )
194}
195///
196/// # When to use
197/// Use this when you need an editable text input but want full control over the
198/// styling (no built-in borders or labels).
199///
200/// # Arguments
201///
202/// * `state` - The observable text field state that holds text content and cursor position.
203/// * `modifier` - Modifiers for styling and layout.
204/// * `style` - Text styling (color, font size).
205///
206/// # Example
207///
208/// ```rust,ignore
209/// let text = remember_text_field_state("Initial text");
210/// BasicTextField(text, Modifier::padding(8.0), TextStyle::default());
211/// ```
212#[composable]
213pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
214    BasicTextFieldWithOptions(
215        state,
216        modifier,
217        BasicTextFieldOptions {
218            text_style: style,
219            ..BasicTextFieldOptions::default()
220        },
221    )
222}
223
224/// Options for customizing BasicTextField appearance and behavior.
225#[derive(Debug, Clone, PartialEq)]
226pub struct BasicTextFieldOptions {
227    /// Text style
228    pub text_style: TextStyle,
229    /// Cursor color
230    pub cursor_color: Color,
231    /// Line limits: SingleLine or MultiLine with optional min/max
232    pub line_limits: TextFieldLineLimits,
233}
234
235impl Default for BasicTextFieldOptions {
236    fn default() -> Self {
237        Self {
238            text_style: TextStyle::default(),
239            // The field accent: caret + selection handles solid, selection
240            // highlight at [`SELECTION_HIGHLIGHT_ALPHA`] (the reference blue).
241            cursor_color: Color(0.0, 0.478, 1.0, 1.0),
242            line_limits: TextFieldLineLimits::default(),
243        }
244    }
245}
246
247/// Scope passed to a basic text field decoration box.
248///
249/// The decoration may place arbitrary composables around the editable content,
250/// but must call [`inner_text_field`](Self::inner_text_field) exactly once.
251#[derive(Clone)]
252pub struct BasicTextFieldDecorationScope {
253    inner: Rc<dyn Fn() -> NodeId>,
254}
255
256impl BasicTextFieldDecorationScope {
257    pub fn inner_text_field(&self) -> NodeId {
258        (self.inner)()
259    }
260}
261
262impl PartialEq for BasicTextFieldDecorationScope {
263    fn eq(&self, other: &Self) -> bool {
264        Rc::ptr_eq(&self.inner, &other.inner)
265    }
266}
267
268/// Creates an editable field and lets `decoration_box` place composable labels,
269/// placeholders, icons, buttons, prefixes, or suffixes around it.
270#[composable(no_skip)]
271pub fn BasicTextFieldDecorated<D>(
272    state: TextFieldState,
273    modifier: Modifier,
274    options: BasicTextFieldOptions,
275    decoration_box: D,
276) -> NodeId
277where
278    D: Fn(BasicTextFieldDecorationScope) -> NodeId + 'static,
279{
280    let inner_state = state;
281    let inner_modifier = modifier;
282    let inner_options = options;
283    let scope = BasicTextFieldDecorationScope {
284        inner: Rc::new(move || {
285            BasicTextFieldWithOptions(inner_state, inner_modifier.clone(), inner_options.clone())
286        }),
287    };
288    decoration_box(scope)
289}
290
291/// Creates an editable text field with custom options.
292///
293/// This is the full version of `BasicTextField` with all configuration options.
294#[composable]
295pub fn BasicTextFieldWithOptions(
296    state: TextFieldState,
297    modifier: Modifier,
298    options: BasicTextFieldOptions,
299) -> NodeId {
300    // Read text + selection to create composition dependencies: the field (and
301    // its finger handles) recompose when either changes.
302    let _text = state.text();
303    let _selection = state.selection();
304
305    // Shared channel through which the field node publishes live handle geometry
306    // (focus, direct manipulation, on-screen origin, metrics). Remembered so it is stable
307    // across recompositions.
308    let controller =
309        remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
310
311    // Build the text field element with line limits + the handle controller.
312    // The modal depth this field was composed at (see `local_modal_depth`)
313    // travels with it into the focus request, so a field behind an open
314    // dialog cannot steal focus from it.
315    let modal_depth = crate::modal::local_modal_depth().current();
316    let text_field_element = TextFieldElement::new(state, options.text_style.clone())
317        .with_cursor_color(options.cursor_color)
318        .with_line_limits(options.line_limits)
319        .with_handle_controller(controller.clone())
320        .with_modal_depth(modal_depth);
321
322    // Wrap it in a modifier
323    let text_field_modifier = modifier_element(text_field_element);
324    let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
325    let combined_modifier = modifier.then(final_modifier);
326
327    let node = Layout(combined_modifier, EmptyMeasurePolicy, || {});
328
329    // Scroll the focused field's caret above the soft keyboard (bug 2): asks the
330    // nearest scroll container's `BringIntoViewResponder` to reveal the caret on
331    // focus / caret move / keyboard animation. No-op without a scrollable
332    // ancestor or when the caret already fits.
333    BringCaretIntoView(state, options.text_style.clone(), controller.clone());
334
335    // Direct-manipulation selection handles: a caret handle for a collapsed
336    // selection, start/end lollipops for a range. Mouse, touch and pen share
337    // this path. Rendered in the top-level
338    // overlay via `Popup` so they escape the field's clip and hang outside the
339    // line. A `PopupHost` at the app root (installed by the shell) is
340    // required for them to appear.
341    SelectionHandles(state, options.text_style, controller, options.cursor_color);
342
343    node
344}
345
346#[cfg(test)]
347mod options_tests {
348    use super::*;
349
350    #[test]
351    fn default_options_use_default_line_limits() {
352        let options = BasicTextFieldOptions::default();
353        assert_eq!(options.line_limits, TextFieldLineLimits::default());
354    }
355
356    #[test]
357    fn decoration_scope_invokes_the_inner_field() {
358        let scope = BasicTextFieldDecorationScope {
359            inner: Rc::new(|| 73),
360        };
361        assert_eq!(scope.inner_text_field(), 73);
362    }
363}
364
365/// Window-space rect of the field's caret (the cursor line at byte `offset`),
366/// derived from the field's published handle [`TextFieldHandleMetrics`]. Its top
367/// is the top of the caret's visual line; its height is one line.
368fn caret_window_rect(
369    text: &str,
370    style: &TextStyle,
371    metrics: &TextFieldHandleMetrics,
372    offset: usize,
373) -> Rect {
374    // `handle_tip_window_pos` returns the tight glyph-box BOTTOM (the handle
375    // tip); the caret rect spans that box.
376    let tip = handle_tip_window_pos(text, style, metrics, offset, LineAffinity::Upstream);
377    Rect {
378        x: tip.x,
379        y: tip.y - metrics.glyph_box.1,
380        width: 2.0,
381        height: metrics.glyph_box.1,
382    }
383}
384
385/// Consumer half of bug 2: while the field is focused, asks the nearest scroll
386/// container (via [`local_bring_into_view_responder`]) to scroll the caret clear
387/// of the on-screen keyboard ([`local_ime_insets`]).
388///
389/// The request is triggered only by focus, caret movement, or a change in the
390/// keyboard inset — never by scrolling — so the user is never yanked back while
391/// deliberately scrolling the field out of view. The caret rect handed to the
392/// responder is always recomputed from the live metrics, so the scroll delta is
393/// correct even as the keyboard animates in.
394#[composable]
395fn BringCaretIntoView(
396    state: TextFieldState,
397    style: TextStyle,
398    controller: TextFieldHandleController,
399) {
400    let Some(metrics) = controller.metrics() else {
401        return;
402    };
403    // Read the keyboard inset and responder unconditionally so the composable
404    // re-runs when either changes even before the field is focused.
405    let ime_bottom = local_ime_insets().current().bottom;
406    let responder = local_bring_into_view_responder().current();
407
408    let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
409        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
410
411    if !metrics.focused {
412        // Reset so the next focus re-requests even if the caret/keyboard match a
413        // previous request.
414        previous.set(None);
415        return;
416    }
417    let Some(responder) = responder else {
418        return;
419    };
420
421    let text = state.text();
422    let selection = state.selection();
423    let caret = caret_window_rect(&text, &style, &metrics, selection.start);
424
425    // Trigger key: caret position + keyboard inset (quantised). Deliberately
426    // excludes the field's scroll-driven window origin so scrolling does not
427    // re-fire the request.
428    let key = (
429        selection.start,
430        selection.end,
431        (ime_bottom * 4.0).round() as i64,
432    );
433    SideEffect(move || {
434        if previous.get() != Some(key) {
435            previous.set(Some(key));
436            responder.bring_into_view(caret, ime_bottom);
437        }
438    });
439}
440
441/// Emits selection/cursor handles for a focused field entered through any
442/// primary pointer. Keyboard-only focus keeps a clean caret.
443/// `accent` is the field's tint (its cursor color): handles are drawn solid in
444/// it, matching the caret and the highlight derived from it.
445#[composable]
446fn SelectionHandles(
447    state: TextFieldState,
448    style: TextStyle,
449    controller: TextFieldHandleController,
450    accent: Color,
451) {
452    let selection = state.selection();
453    let current_range = (selection.min(), selection.max());
454    let active_press = controller.press();
455
456    // Whether the contextual menu is open. Reopens whenever the selection range
457    // changes (a fresh selection), so tapping an action dismisses it until the
458    // next selection.
459    let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
460    // Whether the collapsed-caret action popup (Paste / Select all / Undo /
461    // Redo) is open. Opened by tapping the cursor handle; closed by an action or
462    // when the caret leaves the offset it was opened at (typing / tapping
463    // elsewhere). Kept out of the range branch so hook order stays stable.
464    let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
465    // The caret offset the popup was opened at, so it auto-dismisses once the
466    // caret moves away.
467    let caret_menu_offset: Rc<Cell<usize>> =
468        remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
469    let previous_range: Rc<Cell<(usize, usize)>> =
470        remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
471    {
472        let previous_range = Rc::clone(&previous_range);
473        SideEffect(move || {
474            if previous_range.get() != current_range {
475                previous_range.set(current_range);
476                menu_open.set(true);
477            }
478        });
479    }
480    // Auto-dismiss the caret popup once the caret moves off the offset it was
481    // opened at (the user typed or tapped elsewhere), matching Android.
482    {
483        let caret_menu_offset = Rc::clone(&caret_menu_offset);
484        let caret_start = selection.start;
485        SideEffect(move || {
486            if caret_menu_open.value()
487                && (!selection.collapsed() || caret_start != caret_menu_offset.get())
488            {
489                caret_menu_open.set(false);
490            }
491        });
492    }
493
494    let Some(metrics) = controller.metrics() else {
495        return;
496    };
497    if !metrics.focused || !metrics.direct_manipulation {
498        return;
499    }
500
501    let text = state.text();
502
503    // Long-press → slide-to-menu: when the field node publishes a fresh pointer
504    // press, arm a frame-clock watcher that claims the gesture after the hold
505    // threshold (word select; the range-change side effect opens the menu
506    // while the finger is still down). The slot holds the watcher's only
507    // strong reference: replacing/clearing it cancels the pending callback.
508    let press_watcher: Rc<Cell<Option<(u32, u32)>>> =
509        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
510    let press_watcher_ref: Rc<RefCell<Option<Rc<LongPressWatcher>>>> =
511        remember(|| Rc::new(RefCell::new(None))).with(Rc::clone);
512    match active_press {
513        Some(press) => {
514            let key = (press.start.x.to_bits(), press.start.y.to_bits());
515            if press_watcher.get() != Some(key) {
516                press_watcher.set(Some(key));
517                let watcher = Rc::new(LongPressWatcher {
518                    controller: controller.clone(),
519                    state,
520                    style: style.clone(),
521                    start: press.start,
522                    start_nanos: Cell::new(None),
523                    registration: RefCell::new(None),
524                    frame_clock: cranpose_core::with_current_composer(|composer| {
525                        composer.runtime_handle()
526                    })
527                    .frame_clock(),
528                });
529                watcher.arm();
530                *press_watcher_ref.borrow_mut() = Some(watcher);
531            }
532        }
533        None => {
534            press_watcher.set(None);
535            press_watcher_ref.borrow_mut().take();
536        }
537    }
538
539    // Window position of an in-progress handle drag, or `None` when no
540    // handle is being dragged. Drives the glass loupe (below) so it floats
541    // above the finger while the caret/selection edge is being placed.
542    let drag_pos: MutableState<Option<Point>> =
543        remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
544    // One displacement-based grab relationship serves all handles; only one
545    // can own the pointer at a time.
546    let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> =
547        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
548    // Which handle the user moved last: the contextual menu rises above it
549    // (the reference re-anchors the actions near the finger's work). A
550    // fresh selection (range changed with no handle drag in flight, e.g. a
551    // double-tap) re-centers the menu.
552    let last_dragged: Rc<Cell<Option<HandleKind>>> =
553        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
554    let menu_anchor_range: Rc<Cell<(usize, usize)>> =
555        remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
556    {
557        let last_dragged = Rc::clone(&last_dragged);
558        let menu_anchor_range = Rc::clone(&menu_anchor_range);
559        SideEffect(move || {
560            if menu_anchor_range.get() != current_range {
561                menu_anchor_range.set(current_range);
562                if drag_pos.value().is_none() {
563                    last_dragged.set(None);
564                }
565            }
566        });
567    }
568    // LIVE tip-y holders, refreshed every composition. The pointer-input
569    // gesture task starts once per handle kind and holds its first
570    // composition's closures — a tip snapshot captured by value goes stale
571    // the moment the handle moves to another wrapped line, and the next
572    // grab computes its finger-to-line bias against the OLD line (taps on
573    // the handle land on the wrong Y; drags fight the selection and read
574    // as a stuck handle). The closures read these cells at event time.
575    let cursor_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
576    let start_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
577    let end_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
578
579    if selection.collapsed() {
580        // Collapsed caret: a single cursor handle (a dot below the caret).
581        let tip = handle_tip_window_pos(
582            &text,
583            &style,
584            &metrics,
585            selection.start,
586            LineAffinity::Upstream,
587        );
588        let on_drag = drag_caret_closure(
589            state,
590            style.clone(),
591            controller.clone(),
592            Rc::clone(&drag_bias),
593        );
594        // Tapping the cursor handle opens the caret action popup, anchored at
595        // the caret's current offset so it auto-dismisses when the caret moves.
596        let open_caret_menu = {
597            let caret_menu_offset = Rc::clone(&caret_menu_offset);
598            move || {
599                caret_menu_offset.set(state.selection().start);
600                caret_menu_open.set(true);
601            }
602        };
603        let on_tap = open_caret_menu.clone();
604        let on_long_press = open_caret_menu;
605        let grab_bias = Rc::clone(&drag_bias);
606        let end_bias = Rc::clone(&drag_bias);
607        cursor_tip_y.set(tip.y);
608        let tip_y = Rc::clone(&cursor_tip_y);
609        SelectionHandle(
610            HandleKind::Cursor,
611            tip,
612            metrics.glyph_box.1,
613            HANDLE_RADIUS,
614            accent,
615            move |pos| {
616                track_handle_grab(&grab_bias, HandleKind::Cursor, tip_y.get(), pos.y);
617                drag_pos.set(Some(pos));
618                on_drag(pos);
619            },
620            move || {
621                drag_pos.set(None);
622                end_bias.set(None);
623                // The released caret resumes a clean blink cycle: solid
624                // for one full interval, then blinking on schedule.
625                crate::cursor_animation::reset_cursor_blink();
626            },
627            on_long_press,
628            on_tap,
629        );
630
631        // The caret action popup (Paste / Select all / Undo / Redo), floating
632        // just above the caret. It dissolves the moment a handle drag starts
633        // and rematerializes after release (the widget runs the measured
634        // timings; it stays composed while fading).
635        if caret_menu_open.value() {
636            let can_paste = clipboard_can_paste();
637            let can_undo = state.can_undo();
638            let can_redo = state.can_redo();
639            let undo_state = state;
640            let redo_state = state;
641            CaretActionMenu(
642                tip.x,
643                tip.y - metrics.glyph_box.1,
644                drag_pos.value().is_none(),
645                can_paste,
646                can_undo,
647                can_redo,
648                move || {
649                    clipboard_paste_into_focus();
650                    caret_menu_open.set(false);
651                },
652                move || {
653                    dispatch_select_all();
654                    caret_menu_open.set(false);
655                },
656                move || {
657                    undo_state.undo();
658                    crate::request_render_invalidation();
659                    caret_menu_open.set(false);
660                },
661                move || {
662                    redo_state.redo();
663                    crate::request_render_invalidation();
664                    caret_menu_open.set(false);
665                },
666            );
667        }
668    } else {
669        // Range selection: start (leftmost) and end (rightmost) lollipops.
670        let start = selection.min();
671        let end = selection.max();
672        let start_tip =
673            handle_tip_window_pos(&text, &style, &metrics, start, LineAffinity::Downstream);
674        let end_tip = handle_tip_window_pos(&text, &style, &metrics, end, LineAffinity::Upstream);
675
676        let last_dragged_start = Rc::clone(&last_dragged);
677        let last_dragged_end = Rc::clone(&last_dragged);
678        let on_drag_start = drag_edge_closure(
679            HandleKind::SelectionStart,
680            state,
681            style.clone(),
682            controller.clone(),
683            Rc::clone(&drag_bias),
684        );
685        let grab_bias = Rc::clone(&drag_bias);
686        let end_bias = Rc::clone(&drag_bias);
687        start_tip_y.set(start_tip.y);
688        let start_tip_live = Rc::clone(&start_tip_y);
689        SelectionHandle(
690            HandleKind::SelectionStart,
691            start_tip,
692            metrics.glyph_box.1,
693            HANDLE_RADIUS,
694            accent,
695            move |pos| {
696                track_handle_grab(
697                    &grab_bias,
698                    HandleKind::SelectionStart,
699                    start_tip_live.get(),
700                    pos.y,
701                );
702                last_dragged_start.set(Some(HandleKind::SelectionStart));
703                drag_pos.set(Some(pos));
704                on_drag_start(pos);
705            },
706            move || {
707                drag_pos.set(None);
708                end_bias.set(None);
709            },
710            // Long-pressing a handle re-opens the contextual menu even when the
711            // selection range has not changed (e.g. after it was dismissed by a
712            // previous action), so the text actions stay reachable.
713            move || menu_open.set(true),
714            // A tap on a selection-edge handle also re-opens the menu.
715            move || menu_open.set(true),
716        );
717
718        let on_drag_end = drag_edge_closure(
719            HandleKind::SelectionEnd,
720            state,
721            style.clone(),
722            controller.clone(),
723            Rc::clone(&drag_bias),
724        );
725        let grab_bias = Rc::clone(&drag_bias);
726        let end_bias = Rc::clone(&drag_bias);
727        end_tip_y.set(end_tip.y);
728        let end_tip_live = Rc::clone(&end_tip_y);
729        SelectionHandle(
730            HandleKind::SelectionEnd,
731            end_tip,
732            metrics.glyph_box.1,
733            HANDLE_RADIUS,
734            accent,
735            move |pos| {
736                track_handle_grab(
737                    &grab_bias,
738                    HandleKind::SelectionEnd,
739                    end_tip_live.get(),
740                    pos.y,
741                );
742                last_dragged_end.set(Some(HandleKind::SelectionEnd));
743                drag_pos.set(Some(pos));
744                on_drag_end(pos);
745            },
746            move || {
747                drag_pos.set(None);
748                end_bias.set(None);
749            },
750            move || menu_open.set(true),
751            move || menu_open.set(true),
752        );
753
754        // Contextual menu (Copy / Cut / Paste / Select all) floating above the
755        // selection. Actions run against the focused field and dismiss the
756        // menu. It dissolves the moment a handle drag starts and
757        // rematerializes after release (the widget runs the measured timings;
758        // it stays composed while fading).
759        if menu_open.value() {
760            let can_paste = clipboard_can_paste();
761            // A claimed long-press feeds the menu its live finger position:
762            // sliding over items highlights them, the release fires.
763            let slide_point = if controller.gesture_claimed() {
764                active_press.map(|press| press.position)
765            } else {
766                None
767            };
768            // The menu rises above the handle the user moved last (their
769            // attention is there); a fresh selection centers over its
770            // first line as before.
771            let (menu_x, menu_top) = match last_dragged.get() {
772                Some(HandleKind::SelectionStart) => {
773                    (start_tip.x, start_tip.y - metrics.glyph_box.1)
774                }
775                Some(HandleKind::SelectionEnd) | Some(HandleKind::Cursor) => {
776                    (end_tip.x, end_tip.y - metrics.glyph_box.1)
777                }
778                None => (
779                    (start_tip.x + end_tip.x) * 0.5,
780                    start_tip.y - metrics.glyph_box.1,
781                ),
782            };
783            TextSelectionMenu(
784                menu_x,
785                menu_top,
786                drag_pos.value().is_none(),
787                slide_point,
788                can_paste,
789                move || {
790                    if let Some(text) = dispatch_copy() {
791                        clipboard_write_text(&text);
792                    }
793                    menu_open.set(false);
794                },
795                move || {
796                    if let Some(text) = dispatch_cut() {
797                        clipboard_write_text(&text);
798                    }
799                    menu_open.set(false);
800                },
801                move || {
802                    clipboard_paste_into_focus();
803                    menu_open.set(false);
804                },
805                move || {
806                    dispatch_select_all();
807                    menu_open.set(false);
808                },
809            );
810        }
811    }
812
813    // The glass loupe: while a handle drag covers the text line, a liquid
814    // glass bubble floats over the dragged line magnifying the live scene
815    // under the finger (text, highlight, the handle itself — it is a backdrop
816    // lens). Dragging by the dot below the line magnifies nothing. Emitted
817    // unconditionally so the bubble stays mounted through its release
818    // deflation.
819    let loupe_target = drag_pos.value().and_then(|finger| {
820        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
821        let offset = window_pos_to_offset(&text, &style, &metrics, finger, bias);
822        // Upstream: the loupe magnifies the line the FINGER rides — at a
823        // shared wrap boundary that is the upper line the mapping sampled.
824        let line_bottom =
825            handle_tip_window_pos(&text, &style, &metrics, offset, LineAffinity::Upstream).y;
826        loupe_target_for_drag(finger, line_bottom, metrics.glyph_box.1)
827    });
828    SelectionLoupe(loupe_target);
829}
830
831fn track_handle_grab(
832    drag_bias: &Cell<Option<HandleGrabOffset>>,
833    kind: HandleKind,
834    handle_tip_y: f32,
835    finger_y: f32,
836) -> f32 {
837    // Only handles whose dot hangs below the line drift clear of the
838    // finger; the start handle follows it directly.
839    let drifts = kind != HandleKind::SelectionStart;
840    let mut grab = drag_bias
841        .get()
842        .unwrap_or_else(|| HandleGrabOffset::begin_for(handle_tip_y, finger_y, drifts));
843    let bias = grab.track(finger_y);
844    drag_bias.set(Some(grab));
845    bias
846}
847
848/// Builds the drag handler for the collapsed cursor handle: moves the caret to
849/// the dragged position. `drag_bias` is the finger-to-line offset captured at
850/// the grab (see [`window_pos_to_offset`]).
851fn drag_caret_closure(
852    state: TextFieldState,
853    style: TextStyle,
854    controller: TextFieldHandleController,
855    drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
856) -> Rc<dyn Fn(Point)> {
857    Rc::new(move |window_pos: Point| {
858        let Some(metrics) = controller.metrics() else {
859            return;
860        };
861        let text = state.text();
862        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
863        let offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
864        state.set_selection(TextRange::new(offset, offset));
865        // A dragged caret never blinks: suspend the cycle entirely while
866        // the finger owns it; the release restarts a clean cycle (per-event
867        // resets fought the scheduler and produced irregular periods).
868        crate::cursor_animation::suspend_cursor_blink();
869        crate::request_render_invalidation();
870    })
871}
872
873/// Builds the drag handler for a selection start/end handle: extends the
874/// selection to the dragged position while keeping the opposite edge fixed and
875/// never letting the edges cross. `drag_bias` as in [`drag_caret_closure`].
876fn drag_edge_closure(
877    dragged: HandleKind,
878    state: TextFieldState,
879    style: TextStyle,
880    controller: TextFieldHandleController,
881    drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
882) -> Rc<dyn Fn(Point)> {
883    Rc::new(move |window_pos: Point| {
884        let Some(metrics) = controller.metrics() else {
885            return;
886        };
887        let text = state.text();
888        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
889        let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
890        let selection = state.selection();
891        let fixed_edge = match dragged {
892            HandleKind::SelectionStart => selection.max(),
893            _ => selection.min(),
894        };
895        let (min, max) =
896            selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
897        state.set_selection(TextRange::new(min, max));
898        crate::request_render_invalidation();
899    })
900}
901
902#[cfg(test)]
903mod tests {
904    use super::*;
905
906    #[test]
907    fn handle_grab_bias_reads_the_live_tip_not_a_snapshot() {
908        // The pointer gesture task holds its first composition's closures;
909        // a tip-y captured by value goes stale when the handle moves to
910        // another wrapped line and the next grab computes its bias against
911        // the OLD line (wrong-Y taps, stuck-handle drags). The closures
912        // read a live Cell that composition refreshes.
913        let tip_y: Rc<Cell<f32>> = Rc::new(Cell::new(100.0));
914        let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> = Rc::new(Cell::new(None));
915        let grab = {
916            let tip_y = Rc::clone(&tip_y);
917            let drag_bias = Rc::clone(&drag_bias);
918            move |finger_y: f32| {
919                track_handle_grab(&drag_bias, HandleKind::SelectionEnd, tip_y.get(), finger_y)
920            }
921        };
922
923        // The handle has since moved two wrapped lines down (tip 100 -> 148);
924        // composition refreshed the cell, the gesture task did not restart.
925        tip_y.set(148.0);
926        let bias = grab(160.0);
927        assert_eq!(
928            bias,
929            148.0 - 160.0,
930            "the grab bias must anchor on the handle's CURRENT line"
931        );
932    }
933    use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
934    use std::sync::Arc;
935
936    /// Sets up a test runtime and keeps it alive for the duration of the test.
937    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
938        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
939        f()
940    }
941
942    /// Composes just the finger handles for a collapsed caret with the given
943    /// published metrics, and returns the rendered scene. The teardrop
944    /// rasterizes to an image primitive, so counting images counts handles.
945    fn render_collapsed_handles(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
946        use crate::layout::LayoutEngine;
947        use crate::renderer::HeadlessRenderer;
948        use crate::widgets::PopupHost;
949        use cranpose_ui_graphics::Size;
950
951        let mut composition = Composition::new(MemoryApplier::new());
952        let key = location_key(file!(), line!(), column!());
953        let state = TextFieldState::new("hello world");
954
955        let mut content = move || {
956            PopupHost(move || {
957                let controller = TextFieldHandleController::new();
958                controller.publish(TextFieldHandleMetrics {
959                    focused: true,
960                    direct_manipulation,
961                    node_origin: Point { x: 0.0, y: 10.0 },
962                    padding_left: 0.0,
963                    padding_top: 0.0,
964                    scroll_offset: 0.0,
965                    line_height: 18.0,
966                    glyph_box: (0.0, 18.0),
967                    wrap_width: None,
968                });
969                SelectionHandles(
970                    state,
971                    TextStyle::default(),
972                    controller,
973                    Color(0.0, 0.478, 1.0, 1.0),
974                );
975            });
976        };
977
978        composition.render(key, &mut content).expect("render");
979        for _ in 0..16 {
980            if !composition.should_render() {
981                break;
982            }
983            composition.reconcile(key, &mut content).expect("reconcile");
984        }
985        let root = composition.root().expect("root");
986        let handle = composition.runtime_handle();
987        let mut applier = composition.applier_mut();
988        applier.set_runtime_handle(handle);
989        let layout = applier
990            .compute_layout(
991                root,
992                Size {
993                    width: 400.0,
994                    height: 400.0,
995                },
996            )
997            .expect("layout");
998        applier.clear_runtime_handle();
999        drop(applier);
1000        HeadlessRenderer::new().render(&layout)
1001    }
1002
1003    /// Composes the handles + contextual menu for a range selection with the
1004    /// given metrics, returning the rendered scene.
1005    fn render_range_menu(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
1006        use crate::layout::LayoutEngine;
1007        use crate::renderer::HeadlessRenderer;
1008        use crate::widgets::PopupHost;
1009        use cranpose_ui_graphics::Size;
1010
1011        let mut composition = Composition::new(MemoryApplier::new());
1012        let key = location_key(file!(), line!(), column!());
1013        let state = TextFieldState::new("hello world");
1014
1015        let mut content = move || {
1016            PopupHost(move || {
1017                let controller = TextFieldHandleController::new();
1018                if state.selection() != TextRange::new(0, 5) {
1019                    state.set_selection(TextRange::new(0, 5));
1020                }
1021                controller.publish(TextFieldHandleMetrics {
1022                    focused: true,
1023                    direct_manipulation,
1024                    node_origin: Point { x: 0.0, y: 40.0 },
1025                    padding_left: 0.0,
1026                    padding_top: 0.0,
1027                    scroll_offset: 0.0,
1028                    line_height: 18.0,
1029                    glyph_box: (0.0, 18.0),
1030                    wrap_width: None,
1031                });
1032                SelectionHandles(
1033                    state,
1034                    TextStyle::default(),
1035                    controller,
1036                    Color(0.0, 0.478, 1.0, 1.0),
1037                );
1038            });
1039        };
1040
1041        composition.render(key, &mut content).expect("render");
1042        for _ in 0..16 {
1043            if !composition.should_render() {
1044                break;
1045            }
1046            composition.reconcile(key, &mut content).expect("reconcile");
1047        }
1048        let root = composition.root().expect("root");
1049        let handle = composition.runtime_handle();
1050        let mut applier = composition.applier_mut();
1051        applier.set_runtime_handle(handle);
1052        let layout = applier
1053            .compute_layout(
1054                root,
1055                Size {
1056                    width: 400.0,
1057                    height: 400.0,
1058                },
1059            )
1060            .expect("layout");
1061        applier.clear_runtime_handle();
1062        drop(applier);
1063        HeadlessRenderer::new().render(&layout)
1064    }
1065
1066    /// Like [`render_range_menu`], but the metrics + `SelectionHandles` are
1067    /// composed inside a `BoxWithConstraints` (which subcomposes its content off
1068    /// the measure pass), mirroring a real app where text fields live inside
1069    /// `BoxWithConstraints`/`LazyColumn`. The overlay `Popup`s must still reach
1070    /// the enclosing `PopupHost` across the subcomposition boundary.
1071    fn render_range_menu_subcomposed(
1072        direct_manipulation: bool,
1073    ) -> crate::renderer::RecordedRenderScene {
1074        use crate::layout::LayoutEngine;
1075        use crate::renderer::HeadlessRenderer;
1076        use crate::widgets::{BoxWithConstraints, PopupHost};
1077        use cranpose_ui_graphics::Size;
1078
1079        let mut composition = Composition::new(MemoryApplier::new());
1080        let key = location_key(file!(), line!(), column!());
1081        let state = TextFieldState::new("hello world");
1082
1083        let mut content = move || {
1084            PopupHost(move || {
1085                let state = state;
1086                BoxWithConstraints(
1087                    Modifier::empty().size(Size {
1088                        width: 300.0,
1089                        height: 300.0,
1090                    }),
1091                    move |_scope| {
1092                        let controller = TextFieldHandleController::new();
1093                        if state.selection() != TextRange::new(0, 5) {
1094                            state.set_selection(TextRange::new(0, 5));
1095                        }
1096                        controller.publish(TextFieldHandleMetrics {
1097                            focused: true,
1098                            direct_manipulation,
1099                            node_origin: Point { x: 0.0, y: 40.0 },
1100                            padding_left: 0.0,
1101                            padding_top: 0.0,
1102                            scroll_offset: 0.0,
1103                            line_height: 18.0,
1104                            glyph_box: (0.0, 18.0),
1105                            wrap_width: None,
1106                        });
1107                        SelectionHandles(
1108                            state,
1109                            TextStyle::default(),
1110                            controller,
1111                            Color(0.0, 0.478, 1.0, 1.0),
1112                        );
1113                    },
1114                );
1115            });
1116        };
1117
1118        composition.render(key, &mut content).expect("render");
1119        let root = composition.root().expect("root");
1120        let handle = composition.runtime_handle();
1121        let mut scene = None;
1122        // The Popups register during the measure-pass subcomposition, so a
1123        // follow-up frame (reconcile + layout) is needed for the host to render
1124        // them. Alternate the two a few times, as real frames do.
1125        for _ in 0..8 {
1126            for _ in 0..16 {
1127                if !composition.should_render() {
1128                    break;
1129                }
1130                composition.reconcile(key, &mut content).expect("reconcile");
1131            }
1132            let mut applier = composition.applier_mut();
1133            applier.set_runtime_handle(handle.clone());
1134            let layout = applier
1135                .compute_layout(
1136                    root,
1137                    Size {
1138                        width: 400.0,
1139                        height: 400.0,
1140                    },
1141                )
1142                .expect("layout");
1143            applier.clear_runtime_handle();
1144            drop(applier);
1145            scene = Some(HeadlessRenderer::new().render(&layout));
1146        }
1147        scene.expect("scene")
1148    }
1149
1150    /// Like [`render_range_menu_subcomposed`], but the field lives inside a
1151    /// `LazyColumn` *item* — the exact shape of the reported device bug (a
1152    /// multi-line field in a lazy list). The item is subcomposed off the
1153    /// measure pass through `LazyColumn`'s own `SubcomposeLayoutNode`, so the
1154    /// overlay `Popup`s (handles + menu) only reach the enclosing `PopupHost`
1155    /// once the item subcomposition inherits the call-site composition locals.
1156    fn render_range_menu_lazy_column(
1157        direct_manipulation: bool,
1158    ) -> crate::renderer::RecordedRenderScene {
1159        use crate::layout::LayoutEngine;
1160        use crate::renderer::HeadlessRenderer;
1161        use crate::widgets::PopupHost;
1162        use crate::{LazyColumn, LazyColumnSpec};
1163        use cranpose_foundation::lazy::{rememberLazyListState, LazyListScope};
1164        use cranpose_ui_graphics::Size;
1165
1166        let mut composition = Composition::new(MemoryApplier::new());
1167        let key = location_key(file!(), line!(), column!());
1168        let state = TextFieldState::new("hello world");
1169
1170        let mut content = move || {
1171            PopupHost(move || {
1172                let state = state;
1173                let list_state = rememberLazyListState();
1174                LazyColumn(
1175                    Modifier::empty().size(Size {
1176                        width: 300.0,
1177                        height: 300.0,
1178                    }),
1179                    list_state,
1180                    LazyColumnSpec::default(),
1181                    move |scope| {
1182                        let state = state;
1183                        scope.items(1, move |_index| {
1184                            let controller = TextFieldHandleController::new();
1185                            if state.selection() != TextRange::new(0, 5) {
1186                                state.set_selection(TextRange::new(0, 5));
1187                            }
1188                            controller.publish(TextFieldHandleMetrics {
1189                                focused: true,
1190                                direct_manipulation,
1191                                node_origin: Point { x: 0.0, y: 40.0 },
1192                                padding_left: 0.0,
1193                                padding_top: 0.0,
1194                                scroll_offset: 0.0,
1195                                line_height: 18.0,
1196                                glyph_box: (0.0, 18.0),
1197                                wrap_width: None,
1198                            });
1199                            SelectionHandles(
1200                                state,
1201                                TextStyle::default(),
1202                                controller,
1203                                Color(0.0, 0.478, 1.0, 1.0),
1204                            );
1205                        });
1206                    },
1207                );
1208            });
1209        };
1210
1211        composition.render(key, &mut content).expect("render");
1212        let root = composition.root().expect("root");
1213        let handle = composition.runtime_handle();
1214        let mut scene = None;
1215        for _ in 0..8 {
1216            for _ in 0..16 {
1217                if !composition.should_render() {
1218                    break;
1219                }
1220                composition.reconcile(key, &mut content).expect("reconcile");
1221            }
1222            let mut applier = composition.applier_mut();
1223            applier.set_runtime_handle(handle.clone());
1224            let layout = applier
1225                .compute_layout(
1226                    root,
1227                    Size {
1228                        width: 400.0,
1229                        height: 400.0,
1230                    },
1231                )
1232                .expect("layout");
1233            applier.clear_runtime_handle();
1234            drop(applier);
1235            scene = Some(HeadlessRenderer::new().render(&layout));
1236        }
1237        scene.expect("scene")
1238    }
1239
1240    /// Regression for the core device bug: a `BasicTextField` inside a
1241    /// vertically-scrolled container must publish its TRUE composited window
1242    /// origin so the finger selection/cursor handles anchor on the glyphs and
1243    /// follow the list as it scrolls. Before the fix the field origin was only
1244    /// ever sampled from the last pointer event, so it went stale on scroll —
1245    /// the handles rendered offset from the text, did not move while scrolling,
1246    /// and the window→offset inverse mapping (drag → text offset, and the
1247    /// handle-grab hit region) pointed at the wrong character.
1248    #[test]
1249    fn field_window_origin_follows_vertical_scroll() {
1250        use crate::layout::policies::EmptyMeasurePolicy;
1251        use crate::layout::{LayoutBox, LayoutEngine};
1252        use crate::renderer::HeadlessRenderer;
1253        use crate::scroll::ScrollState;
1254        use crate::widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer};
1255        use cranpose_core::{remember, Key};
1256        use cranpose_foundation::modifier_element;
1257        use cranpose_ui_graphics::Size;
1258        use std::cell::RefCell;
1259
1260        let _app_context = crate::render_state::app_context_test_scope();
1261
1262        // `Composition::new` installs the runtime `TextFieldState`/`ScrollState`
1263        // need, so build it before allocating any state.
1264        let mut composition = Composition::new(MemoryApplier::new());
1265        let state = TextFieldState::new("hello world");
1266        let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
1267            Rc::new(RefCell::new(None));
1268        // `ScrollState` allocates a `MutableState`, so it must be created inside
1269        // the composition's runtime; publish it out through a slot to drive it.
1270        let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1271
1272        let spacer_before = 200.0_f32;
1273        let mut content = {
1274            let controller_slot = Rc::clone(&controller_slot);
1275            let scroll_slot = Rc::clone(&scroll_slot);
1276            move || {
1277                let controller_slot = Rc::clone(&controller_slot);
1278                let scroll_slot = Rc::clone(&scroll_slot);
1279                PopupHost(move || {
1280                    let controller = remember(TextFieldHandleController::new)
1281                        .with(TextFieldHandleController::clone);
1282                    *controller_slot.borrow_mut() = Some(controller.clone());
1283                    let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
1284                    *scroll_slot.borrow_mut() = Some(scroll);
1285                    let state = state;
1286                    let controller = controller.clone();
1287                    Column(
1288                        Modifier::empty()
1289                            .size(Size {
1290                                width: 300.0,
1291                                height: 150.0,
1292                            })
1293                            .vertical_scroll(scroll, false),
1294                        ColumnSpec::default(),
1295                        move || {
1296                            Spacer(Size {
1297                                width: 300.0,
1298                                height: spacer_before,
1299                            });
1300                            let element = TextFieldElement::new(state, TextStyle::default())
1301                                .with_handle_controller(controller.clone());
1302                            let field_modifier =
1303                                Modifier::from_parts(vec![modifier_element(element)]);
1304                            Layout(field_modifier, EmptyMeasurePolicy, || {});
1305                            Spacer(Size {
1306                                width: 300.0,
1307                                height: 400.0,
1308                            });
1309                        },
1310                    );
1311                });
1312            }
1313        };
1314
1315        // The field node is the only one carrying a window-origin sink.
1316        fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
1317            if node
1318                .node_data
1319                .modifier_slices()
1320                .text_field_window_origin()
1321                .is_some()
1322            {
1323                return Some(node.rect);
1324            }
1325            node.children.iter().find_map(find_field_rect)
1326        }
1327
1328        fn layout_and_read(
1329            composition: &mut Composition<MemoryApplier>,
1330            key: Key,
1331            content: &mut dyn FnMut(),
1332            controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
1333        ) -> (Point, f32) {
1334            for _ in 0..16 {
1335                if !composition.should_render() {
1336                    break;
1337                }
1338                composition
1339                    .reconcile(key, &mut *content)
1340                    .expect("reconcile");
1341            }
1342            let root = composition.root().expect("root");
1343            let handle = composition.runtime_handle();
1344            let mut applier = composition.applier_mut();
1345            applier.set_runtime_handle(handle);
1346            let layout = applier
1347                .compute_layout(
1348                    root,
1349                    cranpose_ui_graphics::Size {
1350                        width: 400.0,
1351                        height: 600.0,
1352                    },
1353                )
1354                .expect("layout");
1355            applier.clear_runtime_handle();
1356            drop(applier);
1357            let _ = HeadlessRenderer::new().render(&layout);
1358            let field_y = find_field_rect(layout.root()).expect("field placed").y;
1359            let node_origin = controller_slot
1360                .borrow()
1361                .as_ref()
1362                .expect("controller")
1363                .metrics()
1364                .expect("metrics published")
1365                .node_origin;
1366            (node_origin, field_y)
1367        }
1368
1369        let key = location_key(file!(), line!(), column!());
1370        composition.render(key, &mut content).expect("render");
1371
1372        let (origin0, field_y0) =
1373            layout_and_read(&mut composition, key, &mut content, &controller_slot);
1374        // The published origin must equal the field's real placed window rect
1375        // (not a stale pointer sample), and start below the leading spacer.
1376        assert!(
1377            (origin0.y - field_y0).abs() < 0.5,
1378            "published node_origin.y {} must equal the field's placed window-y {}",
1379            origin0.y,
1380            field_y0
1381        );
1382        assert!(
1383            origin0.y >= spacer_before - 0.5,
1384            "field should start at/after the {spacer_before}px leading spacer, got {}",
1385            origin0.y
1386        );
1387
1388        // Scroll the list down by 50px; the field must move up by exactly 50px
1389        // and the published origin must track it live.
1390        let scroll = *scroll_slot.borrow().as_ref().expect("scroll state");
1391        scroll.scroll_to(50.0);
1392        assert!(
1393            scroll.value() >= 49.5,
1394            "test setup: content must be tall enough to scroll 50px (got {})",
1395            scroll.value()
1396        );
1397        let (origin1, field_y1) =
1398            layout_and_read(&mut composition, key, &mut content, &controller_slot);
1399        assert!(
1400            (origin1.y - field_y1).abs() < 0.5,
1401            "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
1402            origin1.y,
1403            field_y1
1404        );
1405        assert!(
1406            (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
1407            "scrolling 50px must shift the published field origin up by 50px: \
1408             before {}, after {} (expected {})",
1409            origin0.y,
1410            origin1.y,
1411            origin0.y - 50.0
1412        );
1413    }
1414
1415    /// After a scroll, the window→offset inverse mapping must still resolve the
1416    /// character the finger is over, because it reads the same live composited
1417    /// origin the handles are placed at. Uses the published post-scroll metrics
1418    /// to round-trip every caret offset through
1419    /// `handle_tip_window_pos` → `window_pos_to_offset`.
1420    #[test]
1421    fn window_offset_roundtrip_holds_under_scroll_offset() {
1422        let _app_context = crate::render_state::app_context_test_scope();
1423        let text = "hello world";
1424        let style = TextStyle::default();
1425        // Two different composited origins (as if the list scrolled between them).
1426        for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
1427            let metrics = TextFieldHandleMetrics {
1428                focused: true,
1429                direct_manipulation: true,
1430                node_origin,
1431                padding_left: 4.0,
1432                padding_top: 3.0,
1433                scroll_offset: 0.0,
1434                line_height: 18.0,
1435                glyph_box: (0.0, 18.0),
1436                wrap_width: None,
1437            };
1438            for offset in 0..=text.len() {
1439                if !text.is_char_boundary(offset) {
1440                    continue;
1441                }
1442                // A grab exactly at the tip (the line bottom) captures a zero
1443                // bias; the mapping still resolves the grabbed line's offset.
1444                let tip =
1445                    handle_tip_window_pos(text, &style, &metrics, offset, LineAffinity::Downstream);
1446                let resolved = window_pos_to_offset(text, &style, &metrics, tip, 0.0);
1447                assert_eq!(
1448                    resolved, offset,
1449                    "finger at the tip of offset {offset} must map back to it \
1450                     under origin {node_origin:?}, got {resolved}"
1451                );
1452            }
1453        }
1454    }
1455
1456    /// A long unbroken word wraps MID-WORD, so consecutive visual lines share
1457    /// their boundary byte. A selection END dragged along the upper line's
1458    /// right edge produces exactly that byte — its handle (and the loupe line)
1459    /// must anchor to the UPPER line's end, never one line down at the left
1460    /// edge (the wrapped-multiline handle Y-offset bug reported on device).
1461    /// The START handle at the same byte anchors downstream where the first
1462    /// highlighted glyph renders.
1463    #[test]
1464    fn shared_wrap_boundary_anchors_by_handle_affinity() {
1465        let _app_context = crate::render_state::app_context_test_scope();
1466        with_test_runtime(|| {
1467            let text = "aaaaaaaaaaaaaaaaaaaaaaaa";
1468            let style = TextStyle::default();
1469            let annotated = crate::text::AnnotatedString::from(text);
1470            let full = crate::text::measure_text(&annotated, &style);
1471            // Tight enough to split the word across ≥2 visual lines.
1472            let wrap_width = full.width / 3.0;
1473            let ranges = crate::text::wrapped_line_ranges(
1474                None,
1475                &annotated,
1476                &style,
1477                crate::text::TextLayoutOptions::default(),
1478                Some(wrap_width),
1479            );
1480            assert!(
1481                ranges.len() >= 2,
1482                "test setup: text must wrap, got {ranges:?}"
1483            );
1484            let boundary = ranges[1].start;
1485            assert_eq!(
1486                ranges[0].end, boundary,
1487                "test setup: a mid-word wrap must share its boundary byte, got {ranges:?}"
1488            );
1489
1490            let line_height = 20.0;
1491            let metrics = TextFieldHandleMetrics {
1492                focused: true,
1493                direct_manipulation: true,
1494                node_origin: Point { x: 0.0, y: 0.0 },
1495                padding_left: 0.0,
1496                padding_top: 0.0,
1497                scroll_offset: 0.0,
1498                line_height,
1499                glyph_box: (0.0, line_height),
1500                wrap_width: Some(wrap_width),
1501            };
1502
1503            let end_tip =
1504                handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Upstream);
1505            assert!(
1506                (end_tip.y - line_height).abs() < 0.5,
1507                "end handle must sit on the UPPER line's bottom ({line_height}), got y={}",
1508                end_tip.y
1509            );
1510            assert!(
1511                end_tip.x > 1.0,
1512                "end handle must sit at the upper line's right edge, got x={}",
1513                end_tip.x
1514            );
1515
1516            let start_tip =
1517                handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Downstream);
1518            assert!(
1519                (start_tip.y - 2.0 * line_height).abs() < 0.5,
1520                "start handle must sit on the LOWER line's bottom ({}), got y={}",
1521                2.0 * line_height,
1522                start_tip.y
1523            );
1524            assert!(
1525                start_tip.x.abs() < 0.5,
1526                "start handle must sit at the lower line's left edge, got x={}",
1527                start_tip.x
1528            );
1529
1530            // The inverse mapping must preserve finger-to-handle coordination
1531            // through every grab phase. The finger moves down while the handle
1532            // drifts into view; the resolved visual-line bottom must remain
1533            // nearest `finger + bias` instead of accumulating one line of Y
1534            // error for every soft wrap above it.
1535            let mut grab = HandleGrabOffset::begin(end_tip.y, end_tip.y);
1536            for finger_y in [
1537                end_tip.y,
1538                end_tip.y + 8.0,
1539                end_tip.y + 32.0,
1540                end_tip.y + 80.0,
1541            ] {
1542                let bias = grab.track(finger_y);
1543                let resolved = window_pos_to_offset(
1544                    text,
1545                    &style,
1546                    &metrics,
1547                    Point {
1548                        x: end_tip.x,
1549                        y: finger_y,
1550                    },
1551                    bias,
1552                );
1553                let resolved_tip =
1554                    handle_tip_window_pos(text, &style, &metrics, resolved, LineAffinity::Upstream);
1555                let target_tip_y =
1556                    (finger_y + bias).clamp(line_height, ranges.len() as f32 * line_height);
1557                assert!(
1558                    (resolved_tip.y - target_tip_y).abs() <= line_height * 0.5 + 0.5,
1559                    "finger y={finger_y}, bias={bias} resolved to offset {resolved} at y={}, expected the nearest visual-line bottom to {}",
1560                    resolved_tip.y,
1561                    target_tip_y,
1562                );
1563            }
1564        })
1565    }
1566
1567    fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
1568        use crate::renderer::RenderOp;
1569        scene
1570            .operations()
1571            .iter()
1572            .filter_map(|op| match op {
1573                RenderOp::Text { value, .. } => Some(value.clone()),
1574                _ => None,
1575            })
1576            .collect()
1577    }
1578
1579    /// Composes the caret action popup directly inside a `PopupHost` (as the
1580    /// cursor-handle tap does once `caret_menu_open` is set) and returns the
1581    /// rendered scene, so the item labels can be asserted.
1582    fn render_caret_action_menu(
1583        can_paste: bool,
1584        can_undo: bool,
1585        can_redo: bool,
1586    ) -> crate::renderer::RecordedRenderScene {
1587        use crate::layout::LayoutEngine;
1588        use crate::renderer::HeadlessRenderer;
1589        use crate::widgets::PopupHost;
1590        use cranpose_ui_graphics::Size;
1591
1592        let mut composition = Composition::new(MemoryApplier::new());
1593        let key = location_key(file!(), line!(), column!());
1594
1595        let mut content = move || {
1596            PopupHost(move || {
1597                CaretActionMenu(
1598                    40.0,
1599                    60.0,
1600                    true,
1601                    can_paste,
1602                    can_undo,
1603                    can_redo,
1604                    || {},
1605                    || {},
1606                    || {},
1607                    || {},
1608                );
1609            });
1610        };
1611
1612        composition.render(key, &mut content).expect("render");
1613        for _ in 0..16 {
1614            if !composition.should_render() {
1615                break;
1616            }
1617            composition.reconcile(key, &mut content).expect("reconcile");
1618        }
1619        let root = composition.root().expect("root");
1620        let handle = composition.runtime_handle();
1621        let mut applier = composition.applier_mut();
1622        applier.set_runtime_handle(handle);
1623        let layout = applier
1624            .compute_layout(
1625                root,
1626                Size {
1627                    width: 400.0,
1628                    height: 400.0,
1629                },
1630            )
1631            .expect("layout");
1632        applier.clear_runtime_handle();
1633        drop(applier);
1634        HeadlessRenderer::new().render(&layout)
1635    }
1636
1637    /// Bug (b): the caret action popup offers Paste / Select all / Undo / Redo.
1638    /// Paste is hidden when the clipboard is empty, and Undo/Redo when the
1639    /// field's history has nothing to undo/redo.
1640    #[test]
1641    fn caret_action_menu_shows_paste_select_all_undo_redo() {
1642        let _app_context = crate::render_state::app_context_test_scope();
1643
1644        let all = text_values(&render_caret_action_menu(true, true, true));
1645        for label in ["Paste", "Select all", "Undo", "Redo"] {
1646            assert!(
1647                all.iter().any(|t| t == label),
1648                "caret menu should show {label:?}, got {all:?}"
1649            );
1650        }
1651
1652        // Nothing on the clipboard and an empty history: only Select all.
1653        let bare = text_values(&render_caret_action_menu(false, false, false));
1654        assert!(
1655            bare.iter().any(|t| t == "Select all"),
1656            "Select all is always available, got {bare:?}"
1657        );
1658        assert!(
1659            !bare
1660                .iter()
1661                .any(|t| t == "Paste" || t == "Undo" || t == "Redo"),
1662            "Paste/Undo/Redo must be hidden when unavailable, got {bare:?}"
1663        );
1664    }
1665
1666    #[test]
1667    fn context_menu_shows_for_pointer_selection_on_every_platform() {
1668        let _app_context = crate::render_state::app_context_test_scope();
1669
1670        let touch = text_values(&render_range_menu(true));
1671        assert!(
1672            touch.iter().any(|t| t == "Copy"),
1673            "touch selection should show the Copy menu item, got {touch:?}"
1674        );
1675        assert!(
1676            touch.iter().any(|t| t == "Cut"),
1677            "expected Cut, got {touch:?}"
1678        );
1679        assert!(
1680            touch.iter().any(|t| t == "Select all"),
1681            "expected Select all, got {touch:?}"
1682        );
1683
1684        let mouse = text_values(&render_range_menu(true));
1685        assert!(
1686            mouse.iter().any(|t| t == "Copy"),
1687            "mouse selection must expose the same direct-manipulation menu, got {mouse:?}"
1688        );
1689        let keyboard = text_values(&render_range_menu(false));
1690        assert!(
1691            !keyboard.iter().any(|t| t == "Copy"),
1692            "keyboard-only focus must keep a clean caret, got {keyboard:?}"
1693        );
1694    }
1695
1696    #[test]
1697    fn selection_handles_and_menu_survive_subcomposition() {
1698        // Regression: the selection handles and the contextual menu (all drawn
1699        // through `Popup`) must reach the enclosing `PopupHost` even when the
1700        // text field lives inside a `BoxWithConstraints`/`LazyColumn`, which
1701        // subcomposes its content off the measure pass. Both the two teardrop
1702        // handles and the menu items are expected.
1703        let _app_context = crate::render_state::app_context_test_scope();
1704        let scene = render_range_menu_subcomposed(true);
1705
1706        let texts = text_values(&scene);
1707        assert!(
1708            texts.iter().any(|t| t == "Copy"),
1709            "a touch selection inside a subcomposition should show the Copy menu \
1710             item through the host, got {texts:?}"
1711        );
1712        assert!(
1713            texts.iter().any(|t| t == "Select all"),
1714            "expected Select all inside a subcomposition, got {texts:?}"
1715        );
1716        assert_eq!(
1717            image_count(&scene),
1718            2,
1719            "a touch range selection should show two finger teardrop handles in \
1720             the overlay across the subcomposition boundary"
1721        );
1722    }
1723
1724    #[test]
1725    fn selection_handles_and_menu_survive_lazy_column_item() {
1726        // Regression for the reported device bug: a text field inside a
1727        // `LazyColumn` item shows neither its selection handles nor its context
1728        // menu, because the item is subcomposed off the list's measure pass and
1729        // the overlay `Popup`s lose the enclosing `PopupHost` registry across
1730        // that boundary. After capturing the call-site locals in `LazyColumn`
1731        // the handles + menu reach the host, just like a top-level field.
1732        let _app_context = crate::render_state::app_context_test_scope();
1733        let scene = render_range_menu_lazy_column(true);
1734
1735        let texts = text_values(&scene);
1736        assert!(
1737            texts.iter().any(|t| t == "Copy"),
1738            "a touch selection inside a LazyColumn item should show the Copy menu \
1739             item through the host, got {texts:?}"
1740        );
1741        assert!(
1742            texts.iter().any(|t| t == "Select all"),
1743            "expected Select all inside a LazyColumn item, got {texts:?}"
1744        );
1745        assert_eq!(
1746            image_count(&scene),
1747            2,
1748            "a touch range selection should show two finger teardrop handles in \
1749             the overlay across the LazyColumn item subcomposition boundary"
1750        );
1751    }
1752
1753    fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
1754        use crate::renderer::RenderOp;
1755        use cranpose_ui_graphics::DrawPrimitive;
1756        scene
1757            .operations()
1758            .iter()
1759            .filter(|op| {
1760                matches!(
1761                    op,
1762                    RenderOp::Primitive {
1763                        primitive: DrawPrimitive::Image { .. },
1764                        ..
1765                    }
1766                )
1767            })
1768            .count()
1769    }
1770
1771    #[test]
1772    fn cursor_handle_shows_for_pointer_selection_on_every_platform() {
1773        let _app_context = crate::render_state::app_context_test_scope();
1774        assert_eq!(
1775            image_count(&render_collapsed_handles(true)),
1776            1,
1777            "a touch caret should show one finger cursor handle in the overlay"
1778        );
1779        assert_eq!(
1780            image_count(&render_collapsed_handles(true)),
1781            1,
1782            "a mouse-created caret should expose its draggable handle"
1783        );
1784        assert_eq!(
1785            image_count(&render_collapsed_handles(false)),
1786            0,
1787            "keyboard-only focus should keep a clean caret"
1788        );
1789    }
1790
1791    #[test]
1792    fn basic_text_field_creates_node() {
1793        let _app_context = crate::render_state::app_context_test_scope();
1794        let mut composition = Composition::new(MemoryApplier::new());
1795        let state = TextFieldState::new("Test content");
1796
1797        let result = composition.render(location_key(file!(), line!(), column!()), move || {
1798            BasicTextField(state, Modifier::empty(), TextStyle::default());
1799        });
1800
1801        assert!(result.is_ok());
1802        assert!(composition.root().is_some());
1803    }
1804
1805    #[test]
1806    fn basic_text_field_state_updates() {
1807        let _app_context = crate::render_state::app_context_test_scope();
1808        with_test_runtime(|| {
1809            let state = TextFieldState::new("Hello");
1810            assert_eq!(state.text(), "Hello");
1811
1812            state.edit(|buffer| {
1813                buffer.place_cursor_at_end();
1814                buffer.insert("!");
1815            });
1816
1817            assert_eq!(state.text(), "Hello!");
1818        });
1819    }
1820
1821    /// Bug 2 end-to-end (headless): a `LazyColumn` provides a
1822    /// `BringIntoViewResponder`; its viewport rect is filled by the layout pass;
1823    /// asking it to reveal a caret hidden behind the keyboard scrolls the list
1824    /// FORWARD (revealing lower content), while asking it to reveal an
1825    /// already-visible caret does nothing. This pins the whole responder path:
1826    /// provision through the item subcomposition, the `report_window_rect`
1827    /// viewport sink, `scroll_delta_to_reveal`, and the `dispatch_scroll_delta`
1828    /// sign.
1829    #[test]
1830    fn lazy_column_responder_scrolls_a_hidden_caret_into_view() {
1831        use crate::bring_into_view::local_bring_into_view_responder;
1832        use crate::layout::LayoutEngine;
1833        use crate::renderer::HeadlessRenderer;
1834        use crate::widgets::{Box, BoxSpec, PopupHost};
1835        use crate::{LazyColumn, LazyColumnSpec};
1836        use cranpose_core::Key;
1837        use cranpose_foundation::lazy::{rememberLazyListState, LazyListScope, LazyListState};
1838        use cranpose_ui_graphics::Size;
1839        use std::cell::RefCell;
1840
1841        let _app_context = crate::render_state::app_context_test_scope();
1842        let mut composition = Composition::new(MemoryApplier::new());
1843        let responder_slot: Rc<RefCell<Option<crate::bring_into_view::BringIntoViewResponder>>> =
1844            Rc::new(RefCell::new(None));
1845        let state_slot: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1846
1847        // Viewport 300x400 at window origin (0,0); 30 items of 80px each = 2400px
1848        // of content, so the list can scroll far forward.
1849        let mut content = {
1850            let responder_slot = Rc::clone(&responder_slot);
1851            let state_slot = Rc::clone(&state_slot);
1852            move || {
1853                let responder_slot = Rc::clone(&responder_slot);
1854                let state_slot = Rc::clone(&state_slot);
1855                PopupHost(move || {
1856                    let list_state = rememberLazyListState();
1857                    *state_slot.borrow_mut() = Some(list_state);
1858                    let responder_slot = Rc::clone(&responder_slot);
1859                    LazyColumn(
1860                        Modifier::empty().size(Size {
1861                            width: 300.0,
1862                            height: 400.0,
1863                        }),
1864                        list_state,
1865                        LazyColumnSpec::default(),
1866                        move |scope| {
1867                            let responder_slot = Rc::clone(&responder_slot);
1868                            scope.items(30, move |_index| {
1869                                if responder_slot.borrow().is_none() {
1870                                    if let Some(r) = local_bring_into_view_responder().current() {
1871                                        *responder_slot.borrow_mut() = Some(r);
1872                                    }
1873                                }
1874                                Box(
1875                                    Modifier::empty().size(Size {
1876                                        width: 300.0,
1877                                        height: 80.0,
1878                                    }),
1879                                    BoxSpec::default(),
1880                                    || {},
1881                                );
1882                            });
1883                        },
1884                    );
1885                });
1886            }
1887        };
1888
1889        fn run_layout(
1890            composition: &mut Composition<MemoryApplier>,
1891            key: Key,
1892            content: &mut dyn FnMut(),
1893        ) {
1894            for _ in 0..16 {
1895                if !composition.should_render() {
1896                    break;
1897                }
1898                composition
1899                    .reconcile(key, &mut *content)
1900                    .expect("reconcile");
1901            }
1902            let root = composition.root().expect("root");
1903            let handle = composition.runtime_handle();
1904            let mut applier = composition.applier_mut();
1905            applier.set_runtime_handle(handle);
1906            let layout = applier
1907                .compute_layout(
1908                    root,
1909                    Size {
1910                        width: 400.0,
1911                        height: 600.0,
1912                    },
1913                )
1914                .expect("layout");
1915            applier.clear_runtime_handle();
1916            drop(applier);
1917            let _ = HeadlessRenderer::new().render(&layout);
1918        }
1919
1920        let key = location_key(file!(), line!(), column!());
1921        composition.render(key, &mut content).expect("render");
1922        run_layout(&mut composition, key, &mut content);
1923
1924        let responder = responder_slot
1925            .borrow()
1926            .clone()
1927            .expect("LazyColumn provides a bring-into-view responder to its items");
1928        let list_state = state_slot.borrow().expect("list state captured");
1929        let offset0 = list_state.first_visible_item_scroll_offset();
1930        let index0 = list_state.first_visible_item_index();
1931
1932        // A caret already inside the viewport (y=100, above the fold) must not
1933        // scroll the list.
1934        responder.bring_into_view(
1935            Rect {
1936                x: 10.0,
1937                y: 100.0,
1938                width: 2.0,
1939                height: 20.0,
1940            },
1941            0.0,
1942        );
1943        run_layout(&mut composition, key, &mut content);
1944        assert_eq!(
1945            list_state.first_visible_item_index(),
1946            index0,
1947            "an already-visible caret must not scroll the list"
1948        );
1949        assert!(
1950            (list_state.first_visible_item_scroll_offset() - offset0).abs() < 0.5,
1951            "an already-visible caret must not scroll the list"
1952        );
1953
1954        // A caret hidden behind a keyboard covering the bottom 250px (usable
1955        // region 0..150) sitting at y=360 must scroll the list forward.
1956        responder.bring_into_view(
1957            Rect {
1958                x: 10.0,
1959                y: 360.0,
1960                width: 2.0,
1961                height: 20.0,
1962            },
1963            250.0,
1964        );
1965        run_layout(&mut composition, key, &mut content);
1966        let scrolled_forward = list_state.first_visible_item_index() > index0
1967            || list_state.first_visible_item_scroll_offset() > offset0 + 0.5;
1968        assert!(
1969            scrolled_forward,
1970            "a caret behind the keyboard must scroll the list forward \
1971             (index {} -> {}, offset {:.1} -> {:.1})",
1972            index0,
1973            list_state.first_visible_item_index(),
1974            offset0,
1975            list_state.first_visible_item_scroll_offset(),
1976        );
1977    }
1978}