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