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::{clipboard_read_text, clipboard_write_text};
10use crate::composable;
11use crate::layout::policies::EmptyMeasurePolicy;
12use crate::modifier::Modifier;
13use crate::safe_area::local_ime_insets;
14use crate::text::{measure_text, AnnotatedString, TextStyle};
15use crate::text_field_focus::{dispatch_copy, dispatch_cut, dispatch_paste, dispatch_select_all};
16use crate::text_field_modifier_node::{
17    TextFieldElement, TextFieldHandleController, TextFieldHandleMetrics,
18};
19use crate::text_selection::{selection_after_handle_drag, HandleKind, HANDLE_RADIUS};
20use crate::widgets::{
21    CaretActionMenu, CursorMagnifier, Layout, SelectionHandle, TextSelectionMenu,
22};
23use cranpose_core::{mutableStateOf, remember, MutableState, NodeId, SideEffect};
24use cranpose_foundation::modifier_element;
25use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
26use cranpose_ui_graphics::{Color, Point, Rect};
27use std::cell::Cell;
28use std::rc::Rc;
29
30/// Fill color of the finger selection/cursor handles (Android accent blue).
31const HANDLE_COLOR: Color = Color(0.26, 0.52, 0.96, 1.0);
32
33/// Window-space position where a handle's tip should sit for the caret/selection
34/// endpoint at byte `offset`: the bottom of that offset's visual line.
35fn handle_tip_window_pos(
36    text: &str,
37    style: &TextStyle,
38    metrics: &TextFieldHandleMetrics,
39    offset: usize,
40) -> Point {
41    let offset = offset.min(text.len());
42    // Resolve the caret's VISUAL (wrapped) line so the handle tip anchors on the
43    // same glyph as the drawn caret (the field wraps long lines; counting only
44    // logical `\n` lines would place the handle on the wrong line, far right).
45    let (line_index, line_start) = crate::text_field_modifier_node::caret_visual_line_for_offset(
46        text,
47        style,
48        None,
49        metrics.wrap_width,
50        offset,
51    );
52    let caret_x = measure_text(&AnnotatedString::from(&text[line_start..offset]), style).width;
53    Point {
54        x: metrics.node_origin.x + metrics.padding_left + caret_x - metrics.scroll_offset,
55        y: metrics.node_origin.y
56            + metrics.padding_top
57            + (line_index as f32 + 1.0) * metrics.line_height,
58    }
59}
60
61/// Maps a window-space drag position (finger on the handle bulb) back to the
62/// nearest text byte offset in the field.
63fn window_pos_to_offset(
64    text: &str,
65    style: &TextStyle,
66    metrics: &TextFieldHandleMetrics,
67    window_pos: Point,
68) -> usize {
69    let local_x = (window_pos.x - metrics.node_origin.x - metrics.padding_left
70        + metrics.scroll_offset)
71        .max(0.0);
72    // The bulb hangs one line below its tip, so bias the sampled y up by a line
73    // to select the line the tip points at rather than the one below it.
74    let local_y =
75        (window_pos.y - metrics.node_origin.y - metrics.padding_top - metrics.line_height).max(0.0);
76    crate::text::get_offset_for_position(&AnnotatedString::from(text), style, local_x, local_y)
77}
78///
79/// # When to use
80/// Use this when you need an editable text input but want full control over the
81/// styling (no built-in borders or labels).
82///
83/// # Arguments
84///
85/// * `state` - The observable text field state that holds text content and cursor position.
86/// * `modifier` - Modifiers for styling and layout.
87/// * `style` - Text styling (color, font size).
88///
89/// # Example
90///
91/// ```rust,ignore
92/// let text = remember_text_field_state("Initial text");
93/// BasicTextField(text, Modifier::padding(8.0), TextStyle::default());
94/// ```
95#[composable]
96pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
97    BasicTextFieldWithOptions(
98        state,
99        modifier,
100        BasicTextFieldOptions {
101            text_style: style,
102            ..BasicTextFieldOptions::default()
103        },
104    )
105}
106
107/// Options for customizing BasicTextField appearance and behavior.
108#[derive(Debug, Clone, PartialEq)]
109pub struct BasicTextFieldOptions {
110    /// Text style
111    pub text_style: TextStyle,
112    /// Cursor color
113    pub cursor_color: Color,
114    /// Line limits: SingleLine or MultiLine with optional min/max
115    pub line_limits: TextFieldLineLimits,
116}
117
118impl Default for BasicTextFieldOptions {
119    fn default() -> Self {
120        Self {
121            text_style: TextStyle::default(),
122            cursor_color: Color(0.0, 0.0, 0.0, 1.0), // Black
123            line_limits: TextFieldLineLimits::default(),
124        }
125    }
126}
127
128/// Creates an editable text field with custom options.
129///
130/// This is the full version of `BasicTextField` with all configuration options.
131#[composable]
132pub fn BasicTextFieldWithOptions(
133    state: TextFieldState,
134    modifier: Modifier,
135    options: BasicTextFieldOptions,
136) -> NodeId {
137    // Read text + selection to create composition dependencies: the field (and
138    // its finger handles) recompose when either changes.
139    let _text = state.text();
140    let _selection = state.selection();
141
142    // Shared channel through which the field node publishes live handle geometry
143    // (focus, touch, on-screen origin, metrics). Remembered so it is stable
144    // across recompositions.
145    let controller =
146        remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
147
148    // Build the text field element with line limits + the handle controller.
149    let text_field_element = TextFieldElement::new(state.clone(), options.text_style.clone())
150        .with_cursor_color(options.cursor_color)
151        .with_line_limits(options.line_limits)
152        .with_handle_controller(controller.clone());
153
154    // Wrap it in a modifier
155    let text_field_modifier = modifier_element(text_field_element);
156    let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
157    let combined_modifier = modifier.then(final_modifier);
158
159    // Use EmptyMeasurePolicy - TextFieldModifierNode handles all measurement
160    // This matches Jetpack Compose's BasicTextField architecture
161    let node = Layout(
162        combined_modifier,
163        EmptyMeasurePolicy,
164        || {}, // No children
165    );
166
167    // Scroll the focused field's caret above the soft keyboard (bug 2): asks the
168    // nearest scroll container's `BringIntoViewResponder` to reveal the caret on
169    // focus / caret move / keyboard animation. No-op without a scrollable
170    // ancestor or when the caret already fits.
171    BringCaretIntoView(
172        state.clone(),
173        options.text_style.clone(),
174        controller.clone(),
175    );
176
177    // Finger selection handles (touch only): a caret handle for a collapsed
178    // selection, start/end teardrops for a range. Rendered in the top-level
179    // overlay via `Popup` so they escape the field's clip and hang below the
180    // last line. A `PopupHost` at the app root (installed by the shell) is
181    // required for them to appear.
182    SelectionHandles(state, options.text_style, controller);
183
184    node
185}
186
187/// Window-space rect of the field's caret (the cursor line at byte `offset`),
188/// derived from the field's published handle [`TextFieldHandleMetrics`]. Its top
189/// is the top of the caret's visual line; its height is one line.
190fn caret_window_rect(
191    text: &str,
192    style: &TextStyle,
193    metrics: &TextFieldHandleMetrics,
194    offset: usize,
195) -> Rect {
196    // `handle_tip_window_pos` returns the BOTTOM of the caret line (the handle
197    // tip); the caret rect starts one line-height above it.
198    let tip = handle_tip_window_pos(text, style, metrics, offset);
199    Rect {
200        x: tip.x,
201        y: tip.y - metrics.line_height,
202        width: 2.0,
203        height: metrics.line_height,
204    }
205}
206
207/// Consumer half of bug 2: while the field is focused, asks the nearest scroll
208/// container (via [`local_bring_into_view_responder`]) to scroll the caret clear
209/// of the on-screen keyboard ([`local_ime_insets`]).
210///
211/// The request is triggered only by focus, caret movement, or a change in the
212/// keyboard inset — never by scrolling — so the user is never yanked back while
213/// deliberately scrolling the field out of view. The caret rect handed to the
214/// responder is always recomputed from the live metrics, so the scroll delta is
215/// correct even as the keyboard animates in.
216#[composable]
217fn BringCaretIntoView(
218    state: TextFieldState,
219    style: TextStyle,
220    controller: TextFieldHandleController,
221) {
222    let Some(metrics) = controller.metrics() else {
223        return;
224    };
225    // Read the keyboard inset and responder unconditionally so the composable
226    // re-runs when either changes even before the field is focused.
227    let ime_bottom = local_ime_insets().current().bottom;
228    let responder = local_bring_into_view_responder().current();
229
230    let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
231        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
232
233    if !metrics.focused {
234        // Reset so the next focus re-requests even if the caret/keyboard match a
235        // previous request.
236        previous.set(None);
237        return;
238    }
239    let Some(responder) = responder else {
240        return;
241    };
242
243    let text = state.text();
244    let selection = state.selection();
245    let caret = caret_window_rect(&text, &style, &metrics, selection.start);
246
247    // Trigger key: caret position + keyboard inset (quantised). Deliberately
248    // excludes the field's scroll-driven window origin so scrolling does not
249    // re-fire the request.
250    let key = (
251        selection.start,
252        selection.end,
253        (ime_bottom * 4.0).round() as i64,
254    );
255    SideEffect(move || {
256        if previous.get() != Some(key) {
257            previous.set(Some(key));
258            responder.bring_into_view(caret, ime_bottom);
259        }
260    });
261}
262
263/// Emits the finger selection/cursor handles for the field when it is focused
264/// and was last touched (never for mouse input, which keeps a clean caret).
265#[composable]
266fn SelectionHandles(
267    state: TextFieldState,
268    style: TextStyle,
269    controller: TextFieldHandleController,
270) {
271    let selection = state.selection();
272    let current_range = (selection.min(), selection.max());
273
274    // Whether the contextual menu is open. Reopens whenever the selection range
275    // changes (a fresh selection), so tapping an action dismisses it until the
276    // next selection.
277    let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
278    // Whether the collapsed-caret action popup (Paste / Select all / Undo /
279    // Redo) is open. Opened by tapping the cursor handle; closed by an action or
280    // when the caret leaves the offset it was opened at (typing / tapping
281    // elsewhere). Kept out of the range branch so hook order stays stable.
282    let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
283    // The caret offset the popup was opened at, so it auto-dismisses once the
284    // caret moves away.
285    let caret_menu_offset: Rc<Cell<usize>> =
286        remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
287    let previous_range: Rc<Cell<(usize, usize)>> =
288        remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
289    {
290        let previous_range = Rc::clone(&previous_range);
291        SideEffect(move || {
292            if previous_range.get() != current_range {
293                previous_range.set(current_range);
294                menu_open.set(true);
295            }
296        });
297    }
298    // Auto-dismiss the caret popup once the caret moves off the offset it was
299    // opened at (the user typed or tapped elsewhere), matching Android.
300    {
301        let caret_menu_offset = Rc::clone(&caret_menu_offset);
302        let caret_start = selection.start;
303        SideEffect(move || {
304            if caret_menu_open.value()
305                && (!selection.collapsed() || caret_start != caret_menu_offset.get())
306            {
307                caret_menu_open.set(false);
308            }
309        });
310    }
311
312    let Some(metrics) = controller.metrics() else {
313        return;
314    };
315    if !metrics.focused || !metrics.touch {
316        return;
317    }
318
319    let text = state.text();
320
321    // Window position of an in-progress handle drag (touch), or `None` when no
322    // handle is being dragged. Drives the magnifier loupe (below) so it floats
323    // above the finger while the caret/selection edge is being placed.
324    let drag_pos: MutableState<Option<Point>> =
325        remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
326
327    if selection.collapsed() {
328        // Collapsed caret: a single symmetric cursor handle.
329        let tip = handle_tip_window_pos(&text, &style, &metrics, selection.start);
330        let on_drag = drag_caret_closure(state.clone(), style.clone(), controller.clone());
331        // Tapping the cursor handle opens the caret action popup, anchored at
332        // the caret's current offset so it auto-dismisses when the caret moves.
333        let open_caret_menu = {
334            let caret_menu_offset = Rc::clone(&caret_menu_offset);
335            let state = state.clone();
336            move || {
337                caret_menu_offset.set(state.selection().start);
338                caret_menu_open.set(true);
339            }
340        };
341        let on_tap = open_caret_menu.clone();
342        let on_long_press = open_caret_menu;
343        SelectionHandle(
344            HandleKind::Cursor,
345            tip,
346            HANDLE_RADIUS,
347            HANDLE_COLOR,
348            move |pos| {
349                drag_pos.set(Some(pos));
350                on_drag(pos);
351            },
352            move || drag_pos.set(None),
353            on_long_press,
354            on_tap,
355        );
356
357        // The caret action popup (Paste / Select all / Undo / Redo), floating
358        // just above the caret.
359        if caret_menu_open.value() {
360            let caret_anchor = Point {
361                x: tip.x,
362                y: tip.y - metrics.line_height,
363            };
364            let can_paste = clipboard_read_text().is_some();
365            let can_undo = state.can_undo();
366            let can_redo = state.can_redo();
367            let undo_state = state.clone();
368            let redo_state = state.clone();
369            CaretActionMenu(
370                caret_anchor,
371                can_paste,
372                can_undo,
373                can_redo,
374                move || {
375                    if let Some(text) = clipboard_read_text() {
376                        dispatch_paste(&text);
377                    }
378                    caret_menu_open.set(false);
379                },
380                move || {
381                    dispatch_select_all();
382                    caret_menu_open.set(false);
383                },
384                move || {
385                    undo_state.undo();
386                    crate::request_render_invalidation();
387                    caret_menu_open.set(false);
388                },
389                move || {
390                    redo_state.redo();
391                    crate::request_render_invalidation();
392                    caret_menu_open.set(false);
393                },
394            );
395        }
396    } else {
397        // Range selection: start (leftmost) and end (rightmost) teardrops.
398        let start = selection.min();
399        let end = selection.max();
400        let start_tip = handle_tip_window_pos(&text, &style, &metrics, start);
401        let end_tip = handle_tip_window_pos(&text, &style, &metrics, end);
402
403        let on_drag_start = drag_edge_closure(
404            HandleKind::SelectionStart,
405            state.clone(),
406            style.clone(),
407            controller.clone(),
408        );
409        SelectionHandle(
410            HandleKind::SelectionStart,
411            start_tip,
412            HANDLE_RADIUS,
413            HANDLE_COLOR,
414            move |pos| {
415                drag_pos.set(Some(pos));
416                on_drag_start(pos);
417            },
418            move || drag_pos.set(None),
419            // Long-pressing a handle re-opens the contextual menu even when the
420            // selection range has not changed (e.g. after it was dismissed by a
421            // previous action), so the text actions stay reachable.
422            move || menu_open.set(true),
423            // A tap on a selection-edge handle also re-opens the menu.
424            move || menu_open.set(true),
425        );
426
427        let on_drag_end = drag_edge_closure(
428            HandleKind::SelectionEnd,
429            state.clone(),
430            style.clone(),
431            controller.clone(),
432        );
433        SelectionHandle(
434            HandleKind::SelectionEnd,
435            end_tip,
436            HANDLE_RADIUS,
437            HANDLE_COLOR,
438            move |pos| {
439                drag_pos.set(Some(pos));
440                on_drag_end(pos);
441            },
442            move || drag_pos.set(None),
443            move || menu_open.set(true),
444            move || menu_open.set(true),
445        );
446
447        // Contextual menu (Copy / Cut / Paste / Select all) floating above the
448        // selection. Actions run against the focused field and dismiss the menu.
449        if menu_open.value() {
450            // Top-center of the selection's first line.
451            let menu_anchor = Point {
452                x: (start_tip.x + end_tip.x) * 0.5,
453                y: start_tip.y - metrics.line_height,
454            };
455            let can_paste = clipboard_read_text().is_some();
456            TextSelectionMenu(
457                menu_anchor,
458                can_paste,
459                move || {
460                    if let Some(text) = dispatch_copy() {
461                        clipboard_write_text(&text);
462                    }
463                    menu_open.set(false);
464                },
465                move || {
466                    if let Some(text) = dispatch_cut() {
467                        clipboard_write_text(&text);
468                    }
469                    menu_open.set(false);
470                },
471                move || {
472                    if let Some(text) = clipboard_read_text() {
473                        dispatch_paste(&text);
474                    }
475                    menu_open.set(false);
476                },
477                move || {
478                    dispatch_select_all();
479                    menu_open.set(false);
480                },
481            );
482        }
483    }
484
485    // Magnifier loupe: while a handle is being dragged, float a magnified slice
486    // of the text above the finger so it does not obscure the caret it is
487    // placing. Touch-only (a drag position is only ever set from a finger
488    // handle drag). The magnified offset is resolved from the finger's window
489    // position through the SAME live composited metrics the handles use, so the
490    // loupe stays centered on the character under the finger even mid-scroll.
491    if let Some(finger) = drag_pos.value() {
492        let offset = window_pos_to_offset(&text, &style, &metrics, finger);
493        CursorMagnifier(text.clone(), style.clone(), metrics, offset, finger);
494    }
495}
496
497/// Builds the drag handler for the collapsed cursor handle: moves the caret to
498/// the dragged position.
499fn drag_caret_closure(
500    state: TextFieldState,
501    style: TextStyle,
502    controller: TextFieldHandleController,
503) -> Rc<dyn Fn(Point)> {
504    Rc::new(move |window_pos: Point| {
505        let Some(metrics) = controller.metrics() else {
506            return;
507        };
508        let text = state.text();
509        let offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
510        state.set_selection(TextRange::new(offset, offset));
511        crate::request_render_invalidation();
512    })
513}
514
515/// Builds the drag handler for a selection start/end teardrop: extends the
516/// selection to the dragged position while keeping the opposite edge fixed and
517/// never letting the edges cross.
518fn drag_edge_closure(
519    dragged: HandleKind,
520    state: TextFieldState,
521    style: TextStyle,
522    controller: TextFieldHandleController,
523) -> Rc<dyn Fn(Point)> {
524    Rc::new(move |window_pos: Point| {
525        let Some(metrics) = controller.metrics() else {
526            return;
527        };
528        let text = state.text();
529        let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
530        let selection = state.selection();
531        let fixed_edge = match dragged {
532            HandleKind::SelectionStart => selection.max(),
533            _ => selection.min(),
534        };
535        let (min, max) =
536            selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
537        state.set_selection(TextRange::new(min, max));
538        crate::request_render_invalidation();
539    })
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
546    use std::sync::Arc;
547
548    /// Sets up a test runtime and keeps it alive for the duration of the test.
549    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
550        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
551        f()
552    }
553
554    /// Composes just the finger handles for a collapsed caret with the given
555    /// published metrics, and returns the rendered scene. The teardrop
556    /// rasterizes to an image primitive, so counting images counts handles.
557    fn render_collapsed_handles(touch: bool) -> crate::renderer::RecordedRenderScene {
558        use crate::layout::LayoutEngine;
559        use crate::renderer::HeadlessRenderer;
560        use crate::widgets::PopupHost;
561        use cranpose_ui_graphics::Size;
562
563        let mut composition = Composition::new(MemoryApplier::new());
564        let key = location_key(file!(), line!(), column!());
565        let state = TextFieldState::new("hello world");
566
567        let mut content = {
568            let state = state.clone();
569            move || {
570                let state = state.clone();
571                PopupHost(move || {
572                    let controller = TextFieldHandleController::new();
573                    controller.publish(TextFieldHandleMetrics {
574                        focused: true,
575                        touch,
576                        node_origin: Point { x: 0.0, y: 10.0 },
577                        padding_left: 0.0,
578                        padding_top: 0.0,
579                        scroll_offset: 0.0,
580                        line_height: 18.0,
581                        wrap_width: None,
582                    });
583                    SelectionHandles(state.clone(), TextStyle::default(), controller);
584                });
585            }
586        };
587
588        composition.render(key, &mut content).expect("render");
589        for _ in 0..16 {
590            if !composition.should_render() {
591                break;
592            }
593            composition.reconcile(key, &mut content).expect("reconcile");
594        }
595        let root = composition.root().expect("root");
596        let handle = composition.runtime_handle();
597        let mut applier = composition.applier_mut();
598        applier.set_runtime_handle(handle);
599        let layout = applier
600            .compute_layout(
601                root,
602                Size {
603                    width: 400.0,
604                    height: 400.0,
605                },
606            )
607            .expect("layout");
608        applier.clear_runtime_handle();
609        drop(applier);
610        HeadlessRenderer::new().render(&layout)
611    }
612
613    /// Composes the handles + contextual menu for a range selection with the
614    /// given metrics, returning the rendered scene.
615    fn render_range_menu(touch: bool) -> crate::renderer::RecordedRenderScene {
616        use crate::layout::LayoutEngine;
617        use crate::renderer::HeadlessRenderer;
618        use crate::widgets::PopupHost;
619        use cranpose_ui_graphics::Size;
620
621        let mut composition = Composition::new(MemoryApplier::new());
622        let key = location_key(file!(), line!(), column!());
623        let state = TextFieldState::new("hello world");
624
625        let mut content = {
626            let state = state.clone();
627            move || {
628                let state = state.clone();
629                PopupHost(move || {
630                    let controller = TextFieldHandleController::new();
631                    if state.selection() != TextRange::new(0, 5) {
632                        state.set_selection(TextRange::new(0, 5));
633                    }
634                    controller.publish(TextFieldHandleMetrics {
635                        focused: true,
636                        touch,
637                        node_origin: Point { x: 0.0, y: 40.0 },
638                        padding_left: 0.0,
639                        padding_top: 0.0,
640                        scroll_offset: 0.0,
641                        line_height: 18.0,
642                        wrap_width: None,
643                    });
644                    SelectionHandles(state.clone(), TextStyle::default(), controller);
645                });
646            }
647        };
648
649        composition.render(key, &mut content).expect("render");
650        for _ in 0..16 {
651            if !composition.should_render() {
652                break;
653            }
654            composition.reconcile(key, &mut content).expect("reconcile");
655        }
656        let root = composition.root().expect("root");
657        let handle = composition.runtime_handle();
658        let mut applier = composition.applier_mut();
659        applier.set_runtime_handle(handle);
660        let layout = applier
661            .compute_layout(
662                root,
663                Size {
664                    width: 400.0,
665                    height: 400.0,
666                },
667            )
668            .expect("layout");
669        applier.clear_runtime_handle();
670        drop(applier);
671        HeadlessRenderer::new().render(&layout)
672    }
673
674    /// Like [`render_range_menu`], but the metrics + `SelectionHandles` are
675    /// composed inside a `BoxWithConstraints` (which subcomposes its content off
676    /// the measure pass), mirroring a real app where text fields live inside
677    /// `BoxWithConstraints`/`LazyColumn`. The overlay `Popup`s must still reach
678    /// the enclosing `PopupHost` across the subcomposition boundary.
679    fn render_range_menu_subcomposed(touch: bool) -> crate::renderer::RecordedRenderScene {
680        use crate::layout::LayoutEngine;
681        use crate::renderer::HeadlessRenderer;
682        use crate::widgets::{BoxWithConstraints, PopupHost};
683        use cranpose_ui_graphics::Size;
684
685        let mut composition = Composition::new(MemoryApplier::new());
686        let key = location_key(file!(), line!(), column!());
687        let state = TextFieldState::new("hello world");
688
689        let mut content = {
690            let state = state.clone();
691            move || {
692                let state = state.clone();
693                PopupHost(move || {
694                    let state = state.clone();
695                    BoxWithConstraints(
696                        Modifier::empty().size(Size {
697                            width: 300.0,
698                            height: 300.0,
699                        }),
700                        move |_scope| {
701                            let controller = TextFieldHandleController::new();
702                            if state.selection() != TextRange::new(0, 5) {
703                                state.set_selection(TextRange::new(0, 5));
704                            }
705                            controller.publish(TextFieldHandleMetrics {
706                                focused: true,
707                                touch,
708                                node_origin: Point { x: 0.0, y: 40.0 },
709                                padding_left: 0.0,
710                                padding_top: 0.0,
711                                scroll_offset: 0.0,
712                                line_height: 18.0,
713                                wrap_width: None,
714                            });
715                            SelectionHandles(state.clone(), TextStyle::default(), controller);
716                        },
717                    );
718                });
719            }
720        };
721
722        composition.render(key, &mut content).expect("render");
723        let root = composition.root().expect("root");
724        let handle = composition.runtime_handle();
725        let mut scene = None;
726        // The Popups register during the measure-pass subcomposition, so a
727        // follow-up frame (reconcile + layout) is needed for the host to render
728        // them. Alternate the two a few times, as real frames do.
729        for _ in 0..8 {
730            for _ in 0..16 {
731                if !composition.should_render() {
732                    break;
733                }
734                composition.reconcile(key, &mut content).expect("reconcile");
735            }
736            let mut applier = composition.applier_mut();
737            applier.set_runtime_handle(handle.clone());
738            let layout = applier
739                .compute_layout(
740                    root,
741                    Size {
742                        width: 400.0,
743                        height: 400.0,
744                    },
745                )
746                .expect("layout");
747            applier.clear_runtime_handle();
748            drop(applier);
749            scene = Some(HeadlessRenderer::new().render(&layout));
750        }
751        scene.expect("scene")
752    }
753
754    /// Like [`render_range_menu_subcomposed`], but the field lives inside a
755    /// `LazyColumn` *item* — the exact shape of the reported device bug (a
756    /// multi-line field in a lazy list). The item is subcomposed off the
757    /// measure pass through `LazyColumn`'s own `SubcomposeLayoutNode`, so the
758    /// overlay `Popup`s (handles + menu) only reach the enclosing `PopupHost`
759    /// once the item subcomposition inherits the call-site composition locals.
760    fn render_range_menu_lazy_column(touch: bool) -> crate::renderer::RecordedRenderScene {
761        use crate::layout::LayoutEngine;
762        use crate::renderer::HeadlessRenderer;
763        use crate::widgets::PopupHost;
764        use crate::{LazyColumn, LazyColumnSpec};
765        use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope};
766        use cranpose_ui_graphics::Size;
767
768        let mut composition = Composition::new(MemoryApplier::new());
769        let key = location_key(file!(), line!(), column!());
770        let state = TextFieldState::new("hello world");
771
772        let mut content = {
773            let state = state.clone();
774            move || {
775                let state = state.clone();
776                PopupHost(move || {
777                    let state = state.clone();
778                    let list_state = remember_lazy_list_state();
779                    LazyColumn(
780                        Modifier::empty().size(Size {
781                            width: 300.0,
782                            height: 300.0,
783                        }),
784                        list_state,
785                        LazyColumnSpec::default(),
786                        move |scope| {
787                            let state = state.clone();
788                            scope.items(
789                                1,
790                                None::<fn(usize) -> u64>,
791                                None::<fn(usize) -> u64>,
792                                move |_index| {
793                                    let controller = TextFieldHandleController::new();
794                                    if state.selection() != TextRange::new(0, 5) {
795                                        state.set_selection(TextRange::new(0, 5));
796                                    }
797                                    controller.publish(TextFieldHandleMetrics {
798                                        focused: true,
799                                        touch,
800                                        node_origin: Point { x: 0.0, y: 40.0 },
801                                        padding_left: 0.0,
802                                        padding_top: 0.0,
803                                        scroll_offset: 0.0,
804                                        line_height: 18.0,
805                                        wrap_width: None,
806                                    });
807                                    SelectionHandles(
808                                        state.clone(),
809                                        TextStyle::default(),
810                                        controller,
811                                    );
812                                },
813                            );
814                        },
815                    );
816                });
817            }
818        };
819
820        composition.render(key, &mut content).expect("render");
821        let root = composition.root().expect("root");
822        let handle = composition.runtime_handle();
823        let mut scene = None;
824        for _ in 0..8 {
825            for _ in 0..16 {
826                if !composition.should_render() {
827                    break;
828                }
829                composition.reconcile(key, &mut content).expect("reconcile");
830            }
831            let mut applier = composition.applier_mut();
832            applier.set_runtime_handle(handle.clone());
833            let layout = applier
834                .compute_layout(
835                    root,
836                    Size {
837                        width: 400.0,
838                        height: 400.0,
839                    },
840                )
841                .expect("layout");
842            applier.clear_runtime_handle();
843            drop(applier);
844            scene = Some(HeadlessRenderer::new().render(&layout));
845        }
846        scene.expect("scene")
847    }
848
849    /// Regression for the core device bug: a `BasicTextField` inside a
850    /// vertically-scrolled container must publish its TRUE composited window
851    /// origin so the finger selection/cursor handles anchor on the glyphs and
852    /// follow the list as it scrolls. Before the fix the field origin was only
853    /// ever sampled from the last pointer event, so it went stale on scroll —
854    /// the handles rendered offset from the text, did not move while scrolling,
855    /// and the window→offset inverse mapping (drag → text offset, and the
856    /// handle-grab hit region) pointed at the wrong character.
857    #[test]
858    fn field_window_origin_follows_vertical_scroll() {
859        use crate::layout::policies::EmptyMeasurePolicy;
860        use crate::layout::{LayoutBox, LayoutEngine};
861        use crate::renderer::HeadlessRenderer;
862        use crate::scroll::ScrollState;
863        use crate::widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer};
864        use cranpose_core::{remember, Key};
865        use cranpose_foundation::modifier_element;
866        use cranpose_ui_graphics::Size;
867        use std::cell::RefCell;
868
869        let _app_context = crate::render_state::app_context_test_scope();
870
871        // `Composition::new` installs the runtime `TextFieldState`/`ScrollState`
872        // need, so build it before allocating any state.
873        let mut composition = Composition::new(MemoryApplier::new());
874        let state = TextFieldState::new("hello world");
875        let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
876            Rc::new(RefCell::new(None));
877        // `ScrollState` allocates a `MutableState`, so it must be created inside
878        // the composition's runtime; publish it out through a slot to drive it.
879        let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
880
881        let spacer_before = 200.0_f32;
882        let mut content = {
883            let state = state.clone();
884            let controller_slot = Rc::clone(&controller_slot);
885            let scroll_slot = Rc::clone(&scroll_slot);
886            move || {
887                let state = state.clone();
888                let controller_slot = Rc::clone(&controller_slot);
889                let scroll_slot = Rc::clone(&scroll_slot);
890                PopupHost(move || {
891                    let controller = remember(TextFieldHandleController::new)
892                        .with(TextFieldHandleController::clone);
893                    *controller_slot.borrow_mut() = Some(controller.clone());
894                    let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
895                    *scroll_slot.borrow_mut() = Some(scroll.clone());
896                    let state = state.clone();
897                    let controller = controller.clone();
898                    Column(
899                        Modifier::empty()
900                            .size(Size {
901                                width: 300.0,
902                                height: 150.0,
903                            })
904                            .vertical_scroll(scroll.clone(), false),
905                        ColumnSpec::default(),
906                        move || {
907                            Spacer(Size {
908                                width: 300.0,
909                                height: spacer_before,
910                            });
911                            let element =
912                                TextFieldElement::new(state.clone(), TextStyle::default())
913                                    .with_handle_controller(controller.clone());
914                            let field_modifier =
915                                Modifier::from_parts(vec![modifier_element(element)]);
916                            Layout(field_modifier, EmptyMeasurePolicy, || {});
917                            Spacer(Size {
918                                width: 300.0,
919                                height: 400.0,
920                            });
921                        },
922                    );
923                });
924            }
925        };
926
927        // The field node is the only one carrying a window-origin sink.
928        fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
929            if node
930                .node_data
931                .modifier_slices()
932                .text_field_window_origin()
933                .is_some()
934            {
935                return Some(node.rect);
936            }
937            node.children.iter().find_map(find_field_rect)
938        }
939
940        fn layout_and_read(
941            composition: &mut Composition<MemoryApplier>,
942            key: Key,
943            content: &mut dyn FnMut(),
944            controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
945        ) -> (Point, f32) {
946            for _ in 0..16 {
947                if !composition.should_render() {
948                    break;
949                }
950                composition
951                    .reconcile(key, &mut *content)
952                    .expect("reconcile");
953            }
954            let root = composition.root().expect("root");
955            let handle = composition.runtime_handle();
956            let mut applier = composition.applier_mut();
957            applier.set_runtime_handle(handle);
958            let layout = applier
959                .compute_layout(
960                    root,
961                    cranpose_ui_graphics::Size {
962                        width: 400.0,
963                        height: 600.0,
964                    },
965                )
966                .expect("layout");
967            applier.clear_runtime_handle();
968            drop(applier);
969            let _ = HeadlessRenderer::new().render(&layout);
970            let field_y = find_field_rect(layout.root()).expect("field placed").y;
971            let node_origin = controller_slot
972                .borrow()
973                .as_ref()
974                .expect("controller")
975                .metrics()
976                .expect("metrics published")
977                .node_origin;
978            (node_origin, field_y)
979        }
980
981        let key = location_key(file!(), line!(), column!());
982        composition.render(key, &mut content).expect("render");
983
984        let (origin0, field_y0) =
985            layout_and_read(&mut composition, key, &mut content, &controller_slot);
986        // The published origin must equal the field's real placed window rect
987        // (not a stale pointer sample), and start below the leading spacer.
988        assert!(
989            (origin0.y - field_y0).abs() < 0.5,
990            "published node_origin.y {} must equal the field's placed window-y {}",
991            origin0.y,
992            field_y0
993        );
994        assert!(
995            origin0.y >= spacer_before - 0.5,
996            "field should start at/after the {spacer_before}px leading spacer, got {}",
997            origin0.y
998        );
999
1000        // Scroll the list down by 50px; the field must move up by exactly 50px
1001        // and the published origin must track it live.
1002        let scroll = scroll_slot.borrow().as_ref().expect("scroll state").clone();
1003        scroll.scroll_to(50.0);
1004        assert!(
1005            scroll.value() >= 49.5,
1006            "test setup: content must be tall enough to scroll 50px (got {})",
1007            scroll.value()
1008        );
1009        let (origin1, field_y1) =
1010            layout_and_read(&mut composition, key, &mut content, &controller_slot);
1011        assert!(
1012            (origin1.y - field_y1).abs() < 0.5,
1013            "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
1014            origin1.y,
1015            field_y1
1016        );
1017        assert!(
1018            (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
1019            "scrolling 50px must shift the published field origin up by 50px: \
1020             before {}, after {} (expected {})",
1021            origin0.y,
1022            origin1.y,
1023            origin0.y - 50.0
1024        );
1025    }
1026
1027    /// After a scroll, the window→offset inverse mapping must still resolve the
1028    /// character the finger is over, because it reads the same live composited
1029    /// origin the handles are placed at. Uses the published post-scroll metrics
1030    /// to round-trip every caret offset through
1031    /// `handle_tip_window_pos` → `window_pos_to_offset`.
1032    #[test]
1033    fn window_offset_roundtrip_holds_under_scroll_offset() {
1034        let _app_context = crate::render_state::app_context_test_scope();
1035        let text = "hello world";
1036        let style = TextStyle::default();
1037        // Two different composited origins (as if the list scrolled between them).
1038        for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
1039            let metrics = TextFieldHandleMetrics {
1040                focused: true,
1041                touch: true,
1042                node_origin,
1043                padding_left: 4.0,
1044                padding_top: 3.0,
1045                scroll_offset: 0.0,
1046                line_height: 18.0,
1047                wrap_width: None,
1048            };
1049            for offset in 0..=text.len() {
1050                if !text.is_char_boundary(offset) {
1051                    continue;
1052                }
1053                let tip = handle_tip_window_pos(text, &style, &metrics, offset);
1054                let resolved = window_pos_to_offset(text, &style, &metrics, tip);
1055                assert_eq!(
1056                    resolved, offset,
1057                    "finger at the tip of offset {offset} must map back to it \
1058                     under origin {node_origin:?}, got {resolved}"
1059                );
1060            }
1061        }
1062    }
1063
1064    fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
1065        use crate::renderer::RenderOp;
1066        scene
1067            .operations()
1068            .iter()
1069            .filter_map(|op| match op {
1070                RenderOp::Text { value, .. } => Some(value.clone()),
1071                _ => None,
1072            })
1073            .collect()
1074    }
1075
1076    /// Composes the caret action popup directly inside a `PopupHost` (as the
1077    /// cursor-handle tap does once `caret_menu_open` is set) and returns the
1078    /// rendered scene, so the item labels can be asserted.
1079    fn render_caret_action_menu(
1080        can_paste: bool,
1081        can_undo: bool,
1082        can_redo: bool,
1083    ) -> crate::renderer::RecordedRenderScene {
1084        use crate::layout::LayoutEngine;
1085        use crate::renderer::HeadlessRenderer;
1086        use crate::widgets::PopupHost;
1087        use cranpose_ui_graphics::Size;
1088
1089        let mut composition = Composition::new(MemoryApplier::new());
1090        let key = location_key(file!(), line!(), column!());
1091
1092        let mut content = move || {
1093            PopupHost(move || {
1094                CaretActionMenu(
1095                    Point { x: 40.0, y: 60.0 },
1096                    can_paste,
1097                    can_undo,
1098                    can_redo,
1099                    || {},
1100                    || {},
1101                    || {},
1102                    || {},
1103                );
1104            });
1105        };
1106
1107        composition.render(key, &mut content).expect("render");
1108        for _ in 0..16 {
1109            if !composition.should_render() {
1110                break;
1111            }
1112            composition.reconcile(key, &mut content).expect("reconcile");
1113        }
1114        let root = composition.root().expect("root");
1115        let handle = composition.runtime_handle();
1116        let mut applier = composition.applier_mut();
1117        applier.set_runtime_handle(handle);
1118        let layout = applier
1119            .compute_layout(
1120                root,
1121                Size {
1122                    width: 400.0,
1123                    height: 400.0,
1124                },
1125            )
1126            .expect("layout");
1127        applier.clear_runtime_handle();
1128        drop(applier);
1129        HeadlessRenderer::new().render(&layout)
1130    }
1131
1132    /// Bug (b): the caret action popup offers Paste / Select all / Undo / Redo.
1133    /// Paste is hidden when the clipboard is empty, and Undo/Redo when the
1134    /// field's history has nothing to undo/redo.
1135    #[test]
1136    fn caret_action_menu_shows_paste_select_all_undo_redo() {
1137        let _app_context = crate::render_state::app_context_test_scope();
1138
1139        let all = text_values(&render_caret_action_menu(true, true, true));
1140        for label in ["Paste", "Select all", "Undo", "Redo"] {
1141            assert!(
1142                all.iter().any(|t| t == label),
1143                "caret menu should show {label:?}, got {all:?}"
1144            );
1145        }
1146
1147        // Nothing on the clipboard and an empty history: only Select all.
1148        let bare = text_values(&render_caret_action_menu(false, false, false));
1149        assert!(
1150            bare.iter().any(|t| t == "Select all"),
1151            "Select all is always available, got {bare:?}"
1152        );
1153        assert!(
1154            !bare
1155                .iter()
1156                .any(|t| t == "Paste" || t == "Undo" || t == "Redo"),
1157            "Paste/Undo/Redo must be hidden when unavailable, got {bare:?}"
1158        );
1159    }
1160
1161    #[test]
1162    fn context_menu_shows_for_touch_selection_only() {
1163        let _app_context = crate::render_state::app_context_test_scope();
1164
1165        let touch = text_values(&render_range_menu(true));
1166        assert!(
1167            touch.iter().any(|t| t == "Copy"),
1168            "touch selection should show the Copy menu item, got {touch:?}"
1169        );
1170        assert!(
1171            touch.iter().any(|t| t == "Cut"),
1172            "expected Cut, got {touch:?}"
1173        );
1174        assert!(
1175            touch.iter().any(|t| t == "Select all"),
1176            "expected Select all, got {touch:?}"
1177        );
1178
1179        let mouse = text_values(&render_range_menu(false));
1180        assert!(
1181            !mouse.iter().any(|t| t == "Copy"),
1182            "mouse selection must not show the finger contextual menu, got {mouse:?}"
1183        );
1184    }
1185
1186    #[test]
1187    fn selection_handles_and_menu_survive_subcomposition() {
1188        // Regression: the selection handles and the contextual menu (all drawn
1189        // through `Popup`) must reach the enclosing `PopupHost` even when the
1190        // text field lives inside a `BoxWithConstraints`/`LazyColumn`, which
1191        // subcomposes its content off the measure pass. Both the two teardrop
1192        // handles and the menu items are expected.
1193        let _app_context = crate::render_state::app_context_test_scope();
1194        let scene = render_range_menu_subcomposed(true);
1195
1196        let texts = text_values(&scene);
1197        assert!(
1198            texts.iter().any(|t| t == "Copy"),
1199            "a touch selection inside a subcomposition should show the Copy menu \
1200             item through the host, got {texts:?}"
1201        );
1202        assert!(
1203            texts.iter().any(|t| t == "Select all"),
1204            "expected Select all inside a subcomposition, got {texts:?}"
1205        );
1206        assert_eq!(
1207            image_count(&scene),
1208            2,
1209            "a touch range selection should show two finger teardrop handles in \
1210             the overlay across the subcomposition boundary"
1211        );
1212    }
1213
1214    #[test]
1215    fn selection_handles_and_menu_survive_lazy_column_item() {
1216        // Regression for the reported device bug: a text field inside a
1217        // `LazyColumn` item shows neither its selection handles nor its context
1218        // menu, because the item is subcomposed off the list's measure pass and
1219        // the overlay `Popup`s lose the enclosing `PopupHost` registry across
1220        // that boundary. After capturing the call-site locals in `LazyColumn`
1221        // the handles + menu reach the host, just like a top-level field.
1222        let _app_context = crate::render_state::app_context_test_scope();
1223        let scene = render_range_menu_lazy_column(true);
1224
1225        let texts = text_values(&scene);
1226        assert!(
1227            texts.iter().any(|t| t == "Copy"),
1228            "a touch selection inside a LazyColumn item should show the Copy menu \
1229             item through the host, got {texts:?}"
1230        );
1231        assert!(
1232            texts.iter().any(|t| t == "Select all"),
1233            "expected Select all inside a LazyColumn item, got {texts:?}"
1234        );
1235        assert_eq!(
1236            image_count(&scene),
1237            2,
1238            "a touch range selection should show two finger teardrop handles in \
1239             the overlay across the LazyColumn item subcomposition boundary"
1240        );
1241    }
1242
1243    fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
1244        use crate::renderer::RenderOp;
1245        use cranpose_ui_graphics::DrawPrimitive;
1246        scene
1247            .operations()
1248            .iter()
1249            .filter(|op| {
1250                matches!(
1251                    op,
1252                    RenderOp::Primitive {
1253                        primitive: DrawPrimitive::Image { .. },
1254                        ..
1255                    }
1256                )
1257            })
1258            .count()
1259    }
1260
1261    #[test]
1262    fn cursor_handle_shows_for_touch_only() {
1263        let _app_context = crate::render_state::app_context_test_scope();
1264        assert_eq!(
1265            image_count(&render_collapsed_handles(true)),
1266            1,
1267            "a touch caret should show one finger cursor handle in the overlay"
1268        );
1269        assert_eq!(
1270            image_count(&render_collapsed_handles(false)),
1271            0,
1272            "a mouse caret should keep a clean caret with no finger handle"
1273        );
1274    }
1275
1276    #[test]
1277    fn basic_text_field_creates_node() {
1278        let _app_context = crate::render_state::app_context_test_scope();
1279        let mut composition = Composition::new(MemoryApplier::new());
1280        let state = TextFieldState::new("Test content");
1281
1282        let result = composition.render(location_key(file!(), line!(), column!()), {
1283            let state = state.clone();
1284            move || {
1285                BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
1286            }
1287        });
1288
1289        assert!(result.is_ok());
1290        assert!(composition.root().is_some());
1291    }
1292
1293    #[test]
1294    fn basic_text_field_state_updates() {
1295        let _app_context = crate::render_state::app_context_test_scope();
1296        with_test_runtime(|| {
1297            let state = TextFieldState::new("Hello");
1298            assert_eq!(state.text(), "Hello");
1299
1300            state.edit(|buffer| {
1301                buffer.place_cursor_at_end();
1302                buffer.insert("!");
1303            });
1304
1305            assert_eq!(state.text(), "Hello!");
1306        });
1307    }
1308
1309    /// Bug 2 end-to-end (headless): a `LazyColumn` provides a
1310    /// `BringIntoViewResponder`; its viewport rect is filled by the layout pass;
1311    /// asking it to reveal a caret hidden behind the keyboard scrolls the list
1312    /// FORWARD (revealing lower content), while asking it to reveal an
1313    /// already-visible caret does nothing. This pins the whole responder path:
1314    /// provision through the item subcomposition, the `report_window_rect`
1315    /// viewport sink, `scroll_delta_to_reveal`, and the `dispatch_scroll_delta`
1316    /// sign.
1317    #[test]
1318    fn lazy_column_responder_scrolls_a_hidden_caret_into_view() {
1319        use crate::bring_into_view::local_bring_into_view_responder;
1320        use crate::layout::LayoutEngine;
1321        use crate::renderer::HeadlessRenderer;
1322        use crate::widgets::{Box, BoxSpec, PopupHost};
1323        use crate::{LazyColumn, LazyColumnSpec};
1324        use cranpose_core::Key;
1325        use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1326        use cranpose_ui_graphics::Size;
1327        use std::cell::RefCell;
1328
1329        let _app_context = crate::render_state::app_context_test_scope();
1330        let mut composition = Composition::new(MemoryApplier::new());
1331        let responder_slot: Rc<RefCell<Option<crate::bring_into_view::BringIntoViewResponder>>> =
1332            Rc::new(RefCell::new(None));
1333        let state_slot: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1334
1335        // Viewport 300x400 at window origin (0,0); 30 items of 80px each = 2400px
1336        // of content, so the list can scroll far forward.
1337        let mut content = {
1338            let responder_slot = Rc::clone(&responder_slot);
1339            let state_slot = Rc::clone(&state_slot);
1340            move || {
1341                let responder_slot = Rc::clone(&responder_slot);
1342                let state_slot = Rc::clone(&state_slot);
1343                PopupHost(move || {
1344                    let list_state = remember_lazy_list_state();
1345                    *state_slot.borrow_mut() = Some(list_state);
1346                    let responder_slot = Rc::clone(&responder_slot);
1347                    LazyColumn(
1348                        Modifier::empty().size(Size {
1349                            width: 300.0,
1350                            height: 400.0,
1351                        }),
1352                        list_state,
1353                        LazyColumnSpec::default(),
1354                        move |scope| {
1355                            let responder_slot = Rc::clone(&responder_slot);
1356                            scope.items(
1357                                30,
1358                                None::<fn(usize) -> u64>,
1359                                None::<fn(usize) -> u64>,
1360                                move |_index| {
1361                                    if responder_slot.borrow().is_none() {
1362                                        if let Some(r) = local_bring_into_view_responder().current()
1363                                        {
1364                                            *responder_slot.borrow_mut() = Some(r);
1365                                        }
1366                                    }
1367                                    Box(
1368                                        Modifier::empty().size(Size {
1369                                            width: 300.0,
1370                                            height: 80.0,
1371                                        }),
1372                                        BoxSpec::default(),
1373                                        || {},
1374                                    );
1375                                },
1376                            );
1377                        },
1378                    );
1379                });
1380            }
1381        };
1382
1383        fn run_layout(
1384            composition: &mut Composition<MemoryApplier>,
1385            key: Key,
1386            content: &mut dyn FnMut(),
1387        ) {
1388            for _ in 0..16 {
1389                if !composition.should_render() {
1390                    break;
1391                }
1392                composition
1393                    .reconcile(key, &mut *content)
1394                    .expect("reconcile");
1395            }
1396            let root = composition.root().expect("root");
1397            let handle = composition.runtime_handle();
1398            let mut applier = composition.applier_mut();
1399            applier.set_runtime_handle(handle);
1400            let layout = applier
1401                .compute_layout(
1402                    root,
1403                    Size {
1404                        width: 400.0,
1405                        height: 600.0,
1406                    },
1407                )
1408                .expect("layout");
1409            applier.clear_runtime_handle();
1410            drop(applier);
1411            let _ = HeadlessRenderer::new().render(&layout);
1412        }
1413
1414        let key = location_key(file!(), line!(), column!());
1415        composition.render(key, &mut content).expect("render");
1416        run_layout(&mut composition, key, &mut content);
1417
1418        let responder = responder_slot
1419            .borrow()
1420            .clone()
1421            .expect("LazyColumn provides a bring-into-view responder to its items");
1422        let list_state = state_slot.borrow().expect("list state captured");
1423        let offset0 = list_state.first_visible_item_scroll_offset();
1424        let index0 = list_state.first_visible_item_index();
1425
1426        // A caret already inside the viewport (y=100, above the fold) must not
1427        // scroll the list.
1428        responder.bring_into_view(
1429            Rect {
1430                x: 10.0,
1431                y: 100.0,
1432                width: 2.0,
1433                height: 20.0,
1434            },
1435            0.0,
1436        );
1437        run_layout(&mut composition, key, &mut content);
1438        assert_eq!(
1439            list_state.first_visible_item_index(),
1440            index0,
1441            "an already-visible caret must not scroll the list"
1442        );
1443        assert!(
1444            (list_state.first_visible_item_scroll_offset() - offset0).abs() < 0.5,
1445            "an already-visible caret must not scroll the list"
1446        );
1447
1448        // A caret hidden behind a keyboard covering the bottom 250px (usable
1449        // region 0..150) sitting at y=360 must scroll the list forward.
1450        responder.bring_into_view(
1451            Rect {
1452                x: 10.0,
1453                y: 360.0,
1454                width: 2.0,
1455                height: 20.0,
1456            },
1457            250.0,
1458        );
1459        run_layout(&mut composition, key, &mut content);
1460        let scrolled_forward = list_state.first_visible_item_index() > index0
1461            || list_state.first_visible_item_scroll_offset() > offset0 + 0.5;
1462        assert!(
1463            scrolled_forward,
1464            "a caret behind the keyboard must scroll the list forward \
1465             (index {} -> {}, offset {:.1} -> {:.1})",
1466            index0,
1467            list_state.first_visible_item_index(),
1468            offset0,
1469            list_state.first_visible_item_scroll_offset(),
1470        );
1471    }
1472}