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::{Layout, SelectionHandle, TextSelectionMenu};
19use cranpose_core::{mutableStateOf, remember, 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    if selection.collapsed() {
203        // Collapsed caret: a single symmetric cursor handle.
204        let tip = handle_tip_window_pos(&text, &style, &metrics, selection.start);
205        let on_drag = drag_caret_closure(state.clone(), style.clone(), controller.clone());
206        SelectionHandle(
207            HandleKind::Cursor,
208            tip,
209            HANDLE_RADIUS,
210            HANDLE_COLOR,
211            move |pos| on_drag(pos),
212            || {},
213            || {},
214        );
215    } else {
216        // Range selection: start (leftmost) and end (rightmost) teardrops.
217        let start = selection.min();
218        let end = selection.max();
219        let start_tip = handle_tip_window_pos(&text, &style, &metrics, start);
220        let end_tip = handle_tip_window_pos(&text, &style, &metrics, end);
221
222        let on_drag_start = drag_edge_closure(
223            HandleKind::SelectionStart,
224            state.clone(),
225            style.clone(),
226            controller.clone(),
227        );
228        SelectionHandle(
229            HandleKind::SelectionStart,
230            start_tip,
231            HANDLE_RADIUS,
232            HANDLE_COLOR,
233            move |pos| on_drag_start(pos),
234            || {},
235            // Long-pressing a handle re-opens the contextual menu even when the
236            // selection range has not changed (e.g. after it was dismissed by a
237            // previous action), so the text actions stay reachable.
238            move || menu_open.set(true),
239        );
240
241        let on_drag_end = drag_edge_closure(
242            HandleKind::SelectionEnd,
243            state.clone(),
244            style.clone(),
245            controller.clone(),
246        );
247        SelectionHandle(
248            HandleKind::SelectionEnd,
249            end_tip,
250            HANDLE_RADIUS,
251            HANDLE_COLOR,
252            move |pos| on_drag_end(pos),
253            || {},
254            move || menu_open.set(true),
255        );
256
257        // Contextual menu (Copy / Cut / Paste / Select all) floating above the
258        // selection. Actions run against the focused field and dismiss the menu.
259        if menu_open.value() {
260            // Top-center of the selection's first line.
261            let menu_anchor = Point {
262                x: (start_tip.x + end_tip.x) * 0.5,
263                y: start_tip.y - metrics.line_height,
264            };
265            let can_paste = clipboard_read_text().is_some();
266            TextSelectionMenu(
267                menu_anchor,
268                can_paste,
269                move || {
270                    if let Some(text) = dispatch_copy() {
271                        clipboard_write_text(&text);
272                    }
273                    menu_open.set(false);
274                },
275                move || {
276                    if let Some(text) = dispatch_cut() {
277                        clipboard_write_text(&text);
278                    }
279                    menu_open.set(false);
280                },
281                move || {
282                    if let Some(text) = clipboard_read_text() {
283                        dispatch_paste(&text);
284                    }
285                    menu_open.set(false);
286                },
287                move || {
288                    dispatch_select_all();
289                    menu_open.set(false);
290                },
291            );
292        }
293    }
294}
295
296/// Builds the drag handler for the collapsed cursor handle: moves the caret to
297/// the dragged position.
298fn drag_caret_closure(
299    state: TextFieldState,
300    style: TextStyle,
301    controller: TextFieldHandleController,
302) -> Rc<dyn Fn(Point)> {
303    Rc::new(move |window_pos: Point| {
304        let Some(metrics) = controller.metrics() else {
305            return;
306        };
307        let text = state.text();
308        let offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
309        state.set_selection(TextRange::new(offset, offset));
310        crate::request_render_invalidation();
311    })
312}
313
314/// Builds the drag handler for a selection start/end teardrop: extends the
315/// selection to the dragged position while keeping the opposite edge fixed and
316/// never letting the edges cross.
317fn drag_edge_closure(
318    dragged: HandleKind,
319    state: TextFieldState,
320    style: TextStyle,
321    controller: TextFieldHandleController,
322) -> Rc<dyn Fn(Point)> {
323    Rc::new(move |window_pos: Point| {
324        let Some(metrics) = controller.metrics() else {
325            return;
326        };
327        let text = state.text();
328        let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
329        let selection = state.selection();
330        let fixed_edge = match dragged {
331            HandleKind::SelectionStart => selection.max(),
332            _ => selection.min(),
333        };
334        let (min, max) =
335            selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
336        state.set_selection(TextRange::new(min, max));
337        crate::request_render_invalidation();
338    })
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
345    use std::sync::Arc;
346
347    /// Sets up a test runtime and keeps it alive for the duration of the test.
348    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
349        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
350        f()
351    }
352
353    /// Composes just the finger handles for a collapsed caret with the given
354    /// published metrics, and returns the rendered scene. The teardrop
355    /// rasterizes to an image primitive, so counting images counts handles.
356    fn render_collapsed_handles(touch: bool) -> crate::renderer::RecordedRenderScene {
357        use crate::layout::LayoutEngine;
358        use crate::renderer::HeadlessRenderer;
359        use crate::widgets::PopupHost;
360        use cranpose_ui_graphics::Size;
361
362        let mut composition = Composition::new(MemoryApplier::new());
363        let key = location_key(file!(), line!(), column!());
364        let state = TextFieldState::new("hello world");
365
366        let mut content = {
367            let state = state.clone();
368            move || {
369                let state = state.clone();
370                PopupHost(move || {
371                    let controller = TextFieldHandleController::new();
372                    controller.publish(TextFieldHandleMetrics {
373                        focused: true,
374                        touch,
375                        node_origin: Point { x: 0.0, y: 10.0 },
376                        padding_left: 0.0,
377                        padding_top: 0.0,
378                        scroll_offset: 0.0,
379                        line_height: 18.0,
380                    });
381                    SelectionHandles(state.clone(), TextStyle::default(), controller);
382                });
383            }
384        };
385
386        composition.render(key, &mut content).expect("render");
387        for _ in 0..16 {
388            if !composition.should_render() {
389                break;
390            }
391            composition.reconcile(key, &mut content).expect("reconcile");
392        }
393        let root = composition.root().expect("root");
394        let handle = composition.runtime_handle();
395        let mut applier = composition.applier_mut();
396        applier.set_runtime_handle(handle);
397        let layout = applier
398            .compute_layout(
399                root,
400                Size {
401                    width: 400.0,
402                    height: 400.0,
403                },
404            )
405            .expect("layout");
406        applier.clear_runtime_handle();
407        drop(applier);
408        HeadlessRenderer::new().render(&layout)
409    }
410
411    /// Composes the handles + contextual menu for a range selection with the
412    /// given metrics, returning the rendered scene.
413    fn render_range_menu(touch: bool) -> crate::renderer::RecordedRenderScene {
414        use crate::layout::LayoutEngine;
415        use crate::renderer::HeadlessRenderer;
416        use crate::widgets::PopupHost;
417        use cranpose_ui_graphics::Size;
418
419        let mut composition = Composition::new(MemoryApplier::new());
420        let key = location_key(file!(), line!(), column!());
421        let state = TextFieldState::new("hello world");
422
423        let mut content = {
424            let state = state.clone();
425            move || {
426                let state = state.clone();
427                PopupHost(move || {
428                    let controller = TextFieldHandleController::new();
429                    if state.selection() != TextRange::new(0, 5) {
430                        state.set_selection(TextRange::new(0, 5));
431                    }
432                    controller.publish(TextFieldHandleMetrics {
433                        focused: true,
434                        touch,
435                        node_origin: Point { x: 0.0, y: 40.0 },
436                        padding_left: 0.0,
437                        padding_top: 0.0,
438                        scroll_offset: 0.0,
439                        line_height: 18.0,
440                    });
441                    SelectionHandles(state.clone(), TextStyle::default(), controller);
442                });
443            }
444        };
445
446        composition.render(key, &mut content).expect("render");
447        for _ in 0..16 {
448            if !composition.should_render() {
449                break;
450            }
451            composition.reconcile(key, &mut content).expect("reconcile");
452        }
453        let root = composition.root().expect("root");
454        let handle = composition.runtime_handle();
455        let mut applier = composition.applier_mut();
456        applier.set_runtime_handle(handle);
457        let layout = applier
458            .compute_layout(
459                root,
460                Size {
461                    width: 400.0,
462                    height: 400.0,
463                },
464            )
465            .expect("layout");
466        applier.clear_runtime_handle();
467        drop(applier);
468        HeadlessRenderer::new().render(&layout)
469    }
470
471    /// Like [`render_range_menu`], but the metrics + `SelectionHandles` are
472    /// composed inside a `BoxWithConstraints` (which subcomposes its content off
473    /// the measure pass), mirroring a real app where text fields live inside
474    /// `BoxWithConstraints`/`LazyColumn`. The overlay `Popup`s must still reach
475    /// the enclosing `PopupHost` across the subcomposition boundary.
476    fn render_range_menu_subcomposed(touch: bool) -> crate::renderer::RecordedRenderScene {
477        use crate::layout::LayoutEngine;
478        use crate::renderer::HeadlessRenderer;
479        use crate::widgets::{BoxWithConstraints, PopupHost};
480        use cranpose_ui_graphics::Size;
481
482        let mut composition = Composition::new(MemoryApplier::new());
483        let key = location_key(file!(), line!(), column!());
484        let state = TextFieldState::new("hello world");
485
486        let mut content = {
487            let state = state.clone();
488            move || {
489                let state = state.clone();
490                PopupHost(move || {
491                    let state = state.clone();
492                    BoxWithConstraints(
493                        Modifier::empty().size(Size {
494                            width: 300.0,
495                            height: 300.0,
496                        }),
497                        move |_scope| {
498                            let controller = TextFieldHandleController::new();
499                            if state.selection() != TextRange::new(0, 5) {
500                                state.set_selection(TextRange::new(0, 5));
501                            }
502                            controller.publish(TextFieldHandleMetrics {
503                                focused: true,
504                                touch,
505                                node_origin: Point { x: 0.0, y: 40.0 },
506                                padding_left: 0.0,
507                                padding_top: 0.0,
508                                scroll_offset: 0.0,
509                                line_height: 18.0,
510                            });
511                            SelectionHandles(state.clone(), TextStyle::default(), controller);
512                        },
513                    );
514                });
515            }
516        };
517
518        composition.render(key, &mut content).expect("render");
519        let root = composition.root().expect("root");
520        let handle = composition.runtime_handle();
521        let mut scene = None;
522        // The Popups register during the measure-pass subcomposition, so a
523        // follow-up frame (reconcile + layout) is needed for the host to render
524        // them. Alternate the two a few times, as real frames do.
525        for _ in 0..8 {
526            for _ in 0..16 {
527                if !composition.should_render() {
528                    break;
529                }
530                composition.reconcile(key, &mut content).expect("reconcile");
531            }
532            let mut applier = composition.applier_mut();
533            applier.set_runtime_handle(handle.clone());
534            let layout = applier
535                .compute_layout(
536                    root,
537                    Size {
538                        width: 400.0,
539                        height: 400.0,
540                    },
541                )
542                .expect("layout");
543            applier.clear_runtime_handle();
544            drop(applier);
545            scene = Some(HeadlessRenderer::new().render(&layout));
546        }
547        scene.expect("scene")
548    }
549
550    /// Like [`render_range_menu_subcomposed`], but the field lives inside a
551    /// `LazyColumn` *item* — the exact shape of the reported device bug (a
552    /// multi-line field in a lazy list). The item is subcomposed off the
553    /// measure pass through `LazyColumn`'s own `SubcomposeLayoutNode`, so the
554    /// overlay `Popup`s (handles + menu) only reach the enclosing `PopupHost`
555    /// once the item subcomposition inherits the call-site composition locals.
556    fn render_range_menu_lazy_column(touch: bool) -> crate::renderer::RecordedRenderScene {
557        use crate::layout::LayoutEngine;
558        use crate::renderer::HeadlessRenderer;
559        use crate::widgets::PopupHost;
560        use crate::{LazyColumn, LazyColumnSpec};
561        use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope};
562        use cranpose_ui_graphics::Size;
563
564        let mut composition = Composition::new(MemoryApplier::new());
565        let key = location_key(file!(), line!(), column!());
566        let state = TextFieldState::new("hello world");
567
568        let mut content = {
569            let state = state.clone();
570            move || {
571                let state = state.clone();
572                PopupHost(move || {
573                    let state = state.clone();
574                    let list_state = remember_lazy_list_state();
575                    LazyColumn(
576                        Modifier::empty().size(Size {
577                            width: 300.0,
578                            height: 300.0,
579                        }),
580                        list_state,
581                        LazyColumnSpec::default(),
582                        move |scope| {
583                            let state = state.clone();
584                            scope.items(
585                                1,
586                                None::<fn(usize) -> u64>,
587                                None::<fn(usize) -> u64>,
588                                move |_index| {
589                                    let controller = TextFieldHandleController::new();
590                                    if state.selection() != TextRange::new(0, 5) {
591                                        state.set_selection(TextRange::new(0, 5));
592                                    }
593                                    controller.publish(TextFieldHandleMetrics {
594                                        focused: true,
595                                        touch,
596                                        node_origin: Point { x: 0.0, y: 40.0 },
597                                        padding_left: 0.0,
598                                        padding_top: 0.0,
599                                        scroll_offset: 0.0,
600                                        line_height: 18.0,
601                                    });
602                                    SelectionHandles(
603                                        state.clone(),
604                                        TextStyle::default(),
605                                        controller,
606                                    );
607                                },
608                            );
609                        },
610                    );
611                });
612            }
613        };
614
615        composition.render(key, &mut content).expect("render");
616        let root = composition.root().expect("root");
617        let handle = composition.runtime_handle();
618        let mut scene = None;
619        for _ in 0..8 {
620            for _ in 0..16 {
621                if !composition.should_render() {
622                    break;
623                }
624                composition.reconcile(key, &mut content).expect("reconcile");
625            }
626            let mut applier = composition.applier_mut();
627            applier.set_runtime_handle(handle.clone());
628            let layout = applier
629                .compute_layout(
630                    root,
631                    Size {
632                        width: 400.0,
633                        height: 400.0,
634                    },
635                )
636                .expect("layout");
637            applier.clear_runtime_handle();
638            drop(applier);
639            scene = Some(HeadlessRenderer::new().render(&layout));
640        }
641        scene.expect("scene")
642    }
643
644    fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
645        use crate::renderer::RenderOp;
646        scene
647            .operations()
648            .iter()
649            .filter_map(|op| match op {
650                RenderOp::Text { value, .. } => Some(value.clone()),
651                _ => None,
652            })
653            .collect()
654    }
655
656    #[test]
657    fn context_menu_shows_for_touch_selection_only() {
658        let _app_context = crate::render_state::app_context_test_scope();
659
660        let touch = text_values(&render_range_menu(true));
661        assert!(
662            touch.iter().any(|t| t == "Copy"),
663            "touch selection should show the Copy menu item, got {touch:?}"
664        );
665        assert!(
666            touch.iter().any(|t| t == "Cut"),
667            "expected Cut, got {touch:?}"
668        );
669        assert!(
670            touch.iter().any(|t| t == "Select all"),
671            "expected Select all, got {touch:?}"
672        );
673
674        let mouse = text_values(&render_range_menu(false));
675        assert!(
676            !mouse.iter().any(|t| t == "Copy"),
677            "mouse selection must not show the finger contextual menu, got {mouse:?}"
678        );
679    }
680
681    #[test]
682    fn selection_handles_and_menu_survive_subcomposition() {
683        // Regression: the selection handles and the contextual menu (all drawn
684        // through `Popup`) must reach the enclosing `PopupHost` even when the
685        // text field lives inside a `BoxWithConstraints`/`LazyColumn`, which
686        // subcomposes its content off the measure pass. Both the two teardrop
687        // handles and the menu items are expected.
688        let _app_context = crate::render_state::app_context_test_scope();
689        let scene = render_range_menu_subcomposed(true);
690
691        let texts = text_values(&scene);
692        assert!(
693            texts.iter().any(|t| t == "Copy"),
694            "a touch selection inside a subcomposition should show the Copy menu \
695             item through the host, got {texts:?}"
696        );
697        assert!(
698            texts.iter().any(|t| t == "Select all"),
699            "expected Select all inside a subcomposition, got {texts:?}"
700        );
701        assert_eq!(
702            image_count(&scene),
703            2,
704            "a touch range selection should show two finger teardrop handles in \
705             the overlay across the subcomposition boundary"
706        );
707    }
708
709    #[test]
710    fn selection_handles_and_menu_survive_lazy_column_item() {
711        // Regression for the reported device bug: a text field inside a
712        // `LazyColumn` item shows neither its selection handles nor its context
713        // menu, because the item is subcomposed off the list's measure pass and
714        // the overlay `Popup`s lose the enclosing `PopupHost` registry across
715        // that boundary. After capturing the call-site locals in `LazyColumn`
716        // the handles + menu reach the host, just like a top-level field.
717        let _app_context = crate::render_state::app_context_test_scope();
718        let scene = render_range_menu_lazy_column(true);
719
720        let texts = text_values(&scene);
721        assert!(
722            texts.iter().any(|t| t == "Copy"),
723            "a touch selection inside a LazyColumn item should show the Copy menu \
724             item through the host, got {texts:?}"
725        );
726        assert!(
727            texts.iter().any(|t| t == "Select all"),
728            "expected Select all inside a LazyColumn item, got {texts:?}"
729        );
730        assert_eq!(
731            image_count(&scene),
732            2,
733            "a touch range selection should show two finger teardrop handles in \
734             the overlay across the LazyColumn item subcomposition boundary"
735        );
736    }
737
738    fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
739        use crate::renderer::RenderOp;
740        use cranpose_ui_graphics::DrawPrimitive;
741        scene
742            .operations()
743            .iter()
744            .filter(|op| {
745                matches!(
746                    op,
747                    RenderOp::Primitive {
748                        primitive: DrawPrimitive::Image { .. },
749                        ..
750                    }
751                )
752            })
753            .count()
754    }
755
756    #[test]
757    fn cursor_handle_shows_for_touch_only() {
758        let _app_context = crate::render_state::app_context_test_scope();
759        assert_eq!(
760            image_count(&render_collapsed_handles(true)),
761            1,
762            "a touch caret should show one finger cursor handle in the overlay"
763        );
764        assert_eq!(
765            image_count(&render_collapsed_handles(false)),
766            0,
767            "a mouse caret should keep a clean caret with no finger handle"
768        );
769    }
770
771    #[test]
772    fn basic_text_field_creates_node() {
773        let _app_context = crate::render_state::app_context_test_scope();
774        let mut composition = Composition::new(MemoryApplier::new());
775        let state = TextFieldState::new("Test content");
776
777        let result = composition.render(location_key(file!(), line!(), column!()), {
778            let state = state.clone();
779            move || {
780                BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
781            }
782        });
783
784        assert!(result.is_ok());
785        assert!(composition.root().is_some());
786    }
787
788    #[test]
789    fn basic_text_field_state_updates() {
790        let _app_context = crate::render_state::app_context_test_scope();
791        with_test_runtime(|| {
792            let state = TextFieldState::new("Hello");
793            assert_eq!(state.text(), "Hello");
794
795            state.edit(|buffer| {
796                buffer.place_cursor_at_end();
797                buffer.insert("!");
798            });
799
800            assert_eq!(state.text(), "Hello!");
801        });
802    }
803}