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