Skip to main content

cranpose_ui/widgets/
basic_text_field.rs

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