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