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