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