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