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