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