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