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