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