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    let caret = caret_window_rect(&text, &style, &metrics, selection.start);
386
387    let key = (
388        selection.start,
389        selection.end,
390        (ime_bottom * 4.0).round() as i64,
391    );
392    SideEffect(move || {
393        if previous.get() != Some(key) {
394            previous.set(Some(key));
395            responder.bring_into_view(caret, ime_bottom);
396        }
397    });
398}
399
400/// Emits selection/cursor handles for a focused field entered through any
401/// primary pointer. Keyboard-only focus keeps a clean caret.
402/// `accent` is the field's tint (its cursor color): handles are drawn solid in
403/// it, matching the caret and the highlight derived from it.
404#[composable]
405fn SelectionHandles(
406    state: TextFieldState,
407    style: TextStyle,
408    controller: TextFieldHandleController,
409    accent: Color,
410) {
411    let selection = state.selection();
412    let current_range = (selection.min(), selection.max());
413    let active_press = controller.press();
414
415    let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
416    let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
417    let caret_menu_offset: Rc<Cell<usize>> =
418        remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
419    let previous_range: Rc<Cell<(usize, usize)>> =
420        remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
421    {
422        let previous_range = Rc::clone(&previous_range);
423        SideEffect(move || {
424            if previous_range.get() != current_range {
425                previous_range.set(current_range);
426                menu_open.set(true);
427            }
428        });
429    }
430    {
431        let caret_menu_offset = Rc::clone(&caret_menu_offset);
432        let caret_start = selection.start;
433        SideEffect(move || {
434            if caret_menu_open.value()
435                && (!selection.collapsed() || caret_start != caret_menu_offset.get())
436            {
437                caret_menu_open.set(false);
438            }
439        });
440    }
441
442    let Some(metrics) = controller.metrics() else {
443        return;
444    };
445    if !metrics.focused || !metrics.direct_manipulation {
446        return;
447    }
448
449    let text = state.text();
450
451    let press_watcher: Rc<Cell<Option<(u32, u32)>>> =
452        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
453    let press_watcher_ref: Rc<RefCell<Option<Rc<LongPressWatcher>>>> =
454        remember(|| Rc::new(RefCell::new(None))).with(Rc::clone);
455    match active_press {
456        Some(press) => {
457            let key = (press.start.x.to_bits(), press.start.y.to_bits());
458            if press_watcher.get() != Some(key) {
459                press_watcher.set(Some(key));
460                let watcher = Rc::new(LongPressWatcher {
461                    controller: controller.clone(),
462                    state,
463                    style: style.clone(),
464                    start: press.start,
465                    start_nanos: Cell::new(None),
466                    registration: RefCell::new(None),
467                    frame_clock: cranpose_core::with_current_composer(|composer| {
468                        composer.runtime_handle()
469                    })
470                    .frame_clock(),
471                });
472                watcher.arm();
473                *press_watcher_ref.borrow_mut() = Some(watcher);
474            }
475        }
476        None => {
477            press_watcher.set(None);
478            press_watcher_ref.borrow_mut().take();
479        }
480    }
481
482    let drag_pos: MutableState<Option<Point>> =
483        remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
484    let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> =
485        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
486    let last_dragged: Rc<Cell<Option<HandleKind>>> =
487        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
488    let menu_anchor_range: Rc<Cell<(usize, usize)>> =
489        remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
490    {
491        let last_dragged = Rc::clone(&last_dragged);
492        let menu_anchor_range = Rc::clone(&menu_anchor_range);
493        SideEffect(move || {
494            if menu_anchor_range.get() != current_range {
495                menu_anchor_range.set(current_range);
496                if drag_pos.value().is_none() {
497                    last_dragged.set(None);
498                }
499            }
500        });
501    }
502    let cursor_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
503    let start_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
504    let end_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
505
506    if selection.collapsed() {
507        let tip = handle_tip_window_pos(
508            &text,
509            &style,
510            &metrics,
511            selection.start,
512            LineAffinity::Upstream,
513        );
514        let on_drag = drag_caret_closure(
515            state,
516            style.clone(),
517            controller.clone(),
518            Rc::clone(&drag_bias),
519        );
520        let open_caret_menu = {
521            let caret_menu_offset = Rc::clone(&caret_menu_offset);
522            move || {
523                caret_menu_offset.set(state.selection().start);
524                caret_menu_open.set(true);
525            }
526        };
527        let on_tap = open_caret_menu.clone();
528        let on_long_press = open_caret_menu;
529        let grab_bias = Rc::clone(&drag_bias);
530        let end_bias = Rc::clone(&drag_bias);
531        cursor_tip_y.set(tip.y);
532        let tip_y = Rc::clone(&cursor_tip_y);
533        SelectionHandle(
534            HandleKind::Cursor,
535            tip,
536            metrics.glyph_box.1,
537            HANDLE_RADIUS,
538            accent,
539            move |pos| {
540                track_handle_grab(&grab_bias, HandleKind::Cursor, tip_y.get(), pos.y);
541                drag_pos.set(Some(pos));
542                on_drag(pos);
543            },
544            move || {
545                drag_pos.set(None);
546                end_bias.set(None);
547                crate::cursor_animation::reset_cursor_blink();
548            },
549            on_long_press,
550            on_tap,
551        );
552
553        if caret_menu_open.value() {
554            let can_paste = clipboard_can_paste();
555            let can_undo = state.can_undo();
556            let can_redo = state.can_redo();
557            let undo_state = state;
558            let redo_state = state;
559            CaretActionMenu(
560                tip.x,
561                tip.y - metrics.glyph_box.1,
562                drag_pos.value().is_none(),
563                can_paste,
564                can_undo,
565                can_redo,
566                move || {
567                    clipboard_paste_into_focus();
568                    caret_menu_open.set(false);
569                },
570                move || {
571                    dispatch_select_all();
572                    caret_menu_open.set(false);
573                },
574                move || {
575                    undo_state.undo();
576                    crate::request_render_invalidation();
577                    caret_menu_open.set(false);
578                },
579                move || {
580                    redo_state.redo();
581                    crate::request_render_invalidation();
582                    caret_menu_open.set(false);
583                },
584            );
585        }
586    } else {
587        let start = selection.min();
588        let end = selection.max();
589        let start_tip =
590            handle_tip_window_pos(&text, &style, &metrics, start, LineAffinity::Downstream);
591        let end_tip = handle_tip_window_pos(&text, &style, &metrics, end, LineAffinity::Upstream);
592
593        let last_dragged_start = Rc::clone(&last_dragged);
594        let last_dragged_end = Rc::clone(&last_dragged);
595        let on_drag_start = drag_edge_closure(
596            HandleKind::SelectionStart,
597            state,
598            style.clone(),
599            controller.clone(),
600            Rc::clone(&drag_bias),
601        );
602        let grab_bias = Rc::clone(&drag_bias);
603        let end_bias = Rc::clone(&drag_bias);
604        start_tip_y.set(start_tip.y);
605        let start_tip_live = Rc::clone(&start_tip_y);
606        SelectionHandle(
607            HandleKind::SelectionStart,
608            start_tip,
609            metrics.glyph_box.1,
610            HANDLE_RADIUS,
611            accent,
612            move |pos| {
613                track_handle_grab(
614                    &grab_bias,
615                    HandleKind::SelectionStart,
616                    start_tip_live.get(),
617                    pos.y,
618                );
619                last_dragged_start.set(Some(HandleKind::SelectionStart));
620                drag_pos.set(Some(pos));
621                on_drag_start(pos);
622            },
623            move || {
624                drag_pos.set(None);
625                end_bias.set(None);
626            },
627            move || menu_open.set(true),
628            move || menu_open.set(true),
629        );
630
631        let on_drag_end = drag_edge_closure(
632            HandleKind::SelectionEnd,
633            state,
634            style.clone(),
635            controller.clone(),
636            Rc::clone(&drag_bias),
637        );
638        let grab_bias = Rc::clone(&drag_bias);
639        let end_bias = Rc::clone(&drag_bias);
640        end_tip_y.set(end_tip.y);
641        let end_tip_live = Rc::clone(&end_tip_y);
642        SelectionHandle(
643            HandleKind::SelectionEnd,
644            end_tip,
645            metrics.glyph_box.1,
646            HANDLE_RADIUS,
647            accent,
648            move |pos| {
649                track_handle_grab(
650                    &grab_bias,
651                    HandleKind::SelectionEnd,
652                    end_tip_live.get(),
653                    pos.y,
654                );
655                last_dragged_end.set(Some(HandleKind::SelectionEnd));
656                drag_pos.set(Some(pos));
657                on_drag_end(pos);
658            },
659            move || {
660                drag_pos.set(None);
661                end_bias.set(None);
662            },
663            move || menu_open.set(true),
664            move || menu_open.set(true),
665        );
666
667        if menu_open.value() {
668            let can_paste = clipboard_can_paste();
669            let slide_point = if controller.gesture_claimed() {
670                active_press.map(|press| press.position)
671            } else {
672                None
673            };
674            let (menu_x, menu_top) = match last_dragged.get() {
675                Some(HandleKind::SelectionStart) => {
676                    (start_tip.x, start_tip.y - metrics.glyph_box.1)
677                }
678                Some(HandleKind::SelectionEnd) | Some(HandleKind::Cursor) => {
679                    (end_tip.x, end_tip.y - metrics.glyph_box.1)
680                }
681                None => (
682                    (start_tip.x + end_tip.x) * 0.5,
683                    start_tip.y - metrics.glyph_box.1,
684                ),
685            };
686            TextSelectionMenu(
687                menu_x,
688                menu_top,
689                drag_pos.value().is_none(),
690                slide_point,
691                can_paste,
692                move || {
693                    if let Some(text) = dispatch_copy() {
694                        clipboard_write_text(&text);
695                    }
696                    menu_open.set(false);
697                },
698                move || {
699                    if let Some(text) = dispatch_cut() {
700                        clipboard_write_text(&text);
701                    }
702                    menu_open.set(false);
703                },
704                move || {
705                    clipboard_paste_into_focus();
706                    menu_open.set(false);
707                },
708                move || {
709                    dispatch_select_all();
710                    menu_open.set(false);
711                },
712            );
713        }
714    }
715
716    let loupe_target = drag_pos.value().and_then(|finger| {
717        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
718        let offset = window_pos_to_offset(&text, &style, &metrics, finger, bias);
719        let line_bottom =
720            handle_tip_window_pos(&text, &style, &metrics, offset, LineAffinity::Upstream).y;
721        loupe_target_for_drag(finger, line_bottom, metrics.glyph_box.1)
722    });
723    SelectionLoupe(loupe_target);
724}
725
726fn track_handle_grab(
727    drag_bias: &Cell<Option<HandleGrabOffset>>,
728    kind: HandleKind,
729    handle_tip_y: f32,
730    finger_y: f32,
731) -> f32 {
732    let drifts = kind != HandleKind::SelectionStart;
733    let mut grab = drag_bias
734        .get()
735        .unwrap_or_else(|| HandleGrabOffset::begin_for(handle_tip_y, finger_y, drifts));
736    let bias = grab.track(finger_y);
737    drag_bias.set(Some(grab));
738    bias
739}
740
741/// Builds the drag handler for the collapsed cursor handle: moves the caret to
742/// the dragged position. `drag_bias` is the finger-to-line offset captured at
743/// the grab (see [`window_pos_to_offset`]).
744fn drag_caret_closure(
745    state: TextFieldState,
746    style: TextStyle,
747    controller: TextFieldHandleController,
748    drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
749) -> Rc<dyn Fn(Point)> {
750    Rc::new(move |window_pos: Point| {
751        let Some(metrics) = controller.metrics() else {
752            return;
753        };
754        let text = state.text();
755        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
756        let offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
757        state.set_selection(TextRange::new(offset, offset));
758        crate::cursor_animation::suspend_cursor_blink();
759        crate::request_render_invalidation();
760    })
761}
762
763/// Builds the drag handler for a selection start/end handle: extends the
764/// selection to the dragged position while keeping the opposite edge fixed and
765/// never letting the edges cross. `drag_bias` as in [`drag_caret_closure`].
766fn drag_edge_closure(
767    dragged: HandleKind,
768    state: TextFieldState,
769    style: TextStyle,
770    controller: TextFieldHandleController,
771    drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
772) -> Rc<dyn Fn(Point)> {
773    Rc::new(move |window_pos: Point| {
774        let Some(metrics) = controller.metrics() else {
775            return;
776        };
777        let text = state.text();
778        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
779        let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
780        let selection = state.selection();
781        let fixed_edge = match dragged {
782            HandleKind::SelectionStart => selection.max(),
783            _ => selection.min(),
784        };
785        let (min, max) =
786            selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
787        state.set_selection(TextRange::new(min, max));
788        crate::request_render_invalidation();
789    })
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    #[test]
797    fn handle_grab_bias_reads_the_live_tip_not_a_snapshot() {
798        let tip_y: Rc<Cell<f32>> = Rc::new(Cell::new(100.0));
799        let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> = Rc::new(Cell::new(None));
800        let grab = {
801            let tip_y = Rc::clone(&tip_y);
802            let drag_bias = Rc::clone(&drag_bias);
803            move |finger_y: f32| {
804                track_handle_grab(&drag_bias, HandleKind::SelectionEnd, tip_y.get(), finger_y)
805            }
806        };
807
808        tip_y.set(148.0);
809        let bias = grab(160.0);
810        assert_eq!(
811            bias,
812            148.0 - 160.0,
813            "the grab bias must anchor on the handle's CURRENT line"
814        );
815    }
816    use std::sync::Arc;
817
818    use cranpose_core::{Composition, DefaultScheduler, MemoryApplier, Runtime, location_key};
819
820    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
821        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
822        f()
823    }
824
825    fn render_collapsed_handles(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
826        use cranpose_ui_graphics::Size;
827
828        use crate::{layout::LayoutEngine, renderer::HeadlessRenderer, widgets::PopupHost};
829
830        let mut composition = Composition::new(MemoryApplier::new());
831        let key = location_key(file!(), line!(), column!());
832        let state = TextFieldState::new("hello world");
833
834        let mut content = move || {
835            PopupHost(move || {
836                let controller = TextFieldHandleController::new();
837                controller.publish(TextFieldHandleMetrics {
838                    focused: true,
839                    direct_manipulation,
840                    node_origin: Point { x: 0.0, y: 10.0 },
841                    padding_left: 0.0,
842                    padding_top: 0.0,
843                    scroll_offset: 0.0,
844                    line_height: 18.0,
845                    glyph_box: (0.0, 18.0),
846                    wrap_width: None,
847                });
848                SelectionHandles(
849                    state,
850                    TextStyle::default(),
851                    controller,
852                    Color(0.0, 0.478, 1.0, 1.0),
853                );
854            });
855        };
856
857        composition.render(key, &mut content).expect("render");
858        for _ in 0..16 {
859            if !composition.should_render() {
860                break;
861            }
862            composition.reconcile(key, &mut content).expect("reconcile");
863        }
864        let root = composition.root().expect("root");
865        let handle = composition.runtime_handle();
866        let mut applier = composition.applier_mut();
867        applier.set_runtime_handle(handle);
868        let layout = applier
869            .compute_layout(
870                root,
871                Size {
872                    width: 400.0,
873                    height: 400.0,
874                },
875            )
876            .expect("layout");
877        applier.clear_runtime_handle();
878        drop(applier);
879        HeadlessRenderer::new().render(&layout)
880    }
881
882    fn render_range_menu(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
883        use cranpose_ui_graphics::Size;
884
885        use crate::{layout::LayoutEngine, renderer::HeadlessRenderer, widgets::PopupHost};
886
887        let mut composition = Composition::new(MemoryApplier::new());
888        let key = location_key(file!(), line!(), column!());
889        let state = TextFieldState::new("hello world");
890
891        let mut content = move || {
892            PopupHost(move || {
893                let controller = TextFieldHandleController::new();
894                if state.selection() != TextRange::new(0, 5) {
895                    state.set_selection(TextRange::new(0, 5));
896                }
897                controller.publish(TextFieldHandleMetrics {
898                    focused: true,
899                    direct_manipulation,
900                    node_origin: Point { x: 0.0, y: 40.0 },
901                    padding_left: 0.0,
902                    padding_top: 0.0,
903                    scroll_offset: 0.0,
904                    line_height: 18.0,
905                    glyph_box: (0.0, 18.0),
906                    wrap_width: None,
907                });
908                SelectionHandles(
909                    state,
910                    TextStyle::default(),
911                    controller,
912                    Color(0.0, 0.478, 1.0, 1.0),
913                );
914            });
915        };
916
917        composition.render(key, &mut content).expect("render");
918        for _ in 0..16 {
919            if !composition.should_render() {
920                break;
921            }
922            composition.reconcile(key, &mut content).expect("reconcile");
923        }
924        let root = composition.root().expect("root");
925        let handle = composition.runtime_handle();
926        let mut applier = composition.applier_mut();
927        applier.set_runtime_handle(handle);
928        let layout = applier
929            .compute_layout(
930                root,
931                Size {
932                    width: 400.0,
933                    height: 400.0,
934                },
935            )
936            .expect("layout");
937        applier.clear_runtime_handle();
938        drop(applier);
939        HeadlessRenderer::new().render(&layout)
940    }
941
942    fn render_range_menu_subcomposed(
943        direct_manipulation: bool,
944    ) -> crate::renderer::RecordedRenderScene {
945        use cranpose_ui_graphics::Size;
946
947        use crate::{
948            layout::LayoutEngine,
949            renderer::HeadlessRenderer,
950            widgets::{BoxWithConstraints, PopupHost},
951        };
952
953        let mut composition = Composition::new(MemoryApplier::new());
954        let key = location_key(file!(), line!(), column!());
955        let state = TextFieldState::new("hello world");
956
957        let mut content = move || {
958            PopupHost(move || {
959                let state = state;
960                BoxWithConstraints(
961                    Modifier::empty().size(Size {
962                        width: 300.0,
963                        height: 300.0,
964                    }),
965                    move |_scope| {
966                        let controller = TextFieldHandleController::new();
967                        if state.selection() != TextRange::new(0, 5) {
968                            state.set_selection(TextRange::new(0, 5));
969                        }
970                        controller.publish(TextFieldHandleMetrics {
971                            focused: true,
972                            direct_manipulation,
973                            node_origin: Point { x: 0.0, y: 40.0 },
974                            padding_left: 0.0,
975                            padding_top: 0.0,
976                            scroll_offset: 0.0,
977                            line_height: 18.0,
978                            glyph_box: (0.0, 18.0),
979                            wrap_width: None,
980                        });
981                        SelectionHandles(
982                            state,
983                            TextStyle::default(),
984                            controller,
985                            Color(0.0, 0.478, 1.0, 1.0),
986                        );
987                    },
988                );
989            });
990        };
991
992        composition.render(key, &mut content).expect("render");
993        let root = composition.root().expect("root");
994        let handle = composition.runtime_handle();
995        let mut scene = None;
996        for _ in 0..8 {
997            for _ in 0..16 {
998                if !composition.should_render() {
999                    break;
1000                }
1001                composition.reconcile(key, &mut content).expect("reconcile");
1002            }
1003            let mut applier = composition.applier_mut();
1004            applier.set_runtime_handle(handle.clone());
1005            let layout = applier
1006                .compute_layout(
1007                    root,
1008                    Size {
1009                        width: 400.0,
1010                        height: 400.0,
1011                    },
1012                )
1013                .expect("layout");
1014            applier.clear_runtime_handle();
1015            drop(applier);
1016            scene = Some(HeadlessRenderer::new().render(&layout));
1017        }
1018        scene.expect("scene")
1019    }
1020
1021    fn render_range_menu_lazy_column(
1022        direct_manipulation: bool,
1023    ) -> crate::renderer::RecordedRenderScene {
1024        use cranpose_foundation::lazy::{LazyListScope, rememberLazyListState};
1025        use cranpose_ui_graphics::Size;
1026
1027        use crate::{
1028            LazyColumn, LazyColumnSpec, layout::LayoutEngine, renderer::HeadlessRenderer,
1029            widgets::PopupHost,
1030        };
1031
1032        let mut composition = Composition::new(MemoryApplier::new());
1033        let key = location_key(file!(), line!(), column!());
1034        let state = TextFieldState::new("hello world");
1035
1036        let mut content = move || {
1037            PopupHost(move || {
1038                let state = state;
1039                let list_state = rememberLazyListState();
1040                LazyColumn(
1041                    Modifier::empty().size(Size {
1042                        width: 300.0,
1043                        height: 300.0,
1044                    }),
1045                    list_state,
1046                    LazyColumnSpec::default(),
1047                    move |scope| {
1048                        let state = state;
1049                        scope.items(1, move |_index| {
1050                            let controller = TextFieldHandleController::new();
1051                            if state.selection() != TextRange::new(0, 5) {
1052                                state.set_selection(TextRange::new(0, 5));
1053                            }
1054                            controller.publish(TextFieldHandleMetrics {
1055                                focused: true,
1056                                direct_manipulation,
1057                                node_origin: Point { x: 0.0, y: 40.0 },
1058                                padding_left: 0.0,
1059                                padding_top: 0.0,
1060                                scroll_offset: 0.0,
1061                                line_height: 18.0,
1062                                glyph_box: (0.0, 18.0),
1063                                wrap_width: None,
1064                            });
1065                            SelectionHandles(
1066                                state,
1067                                TextStyle::default(),
1068                                controller,
1069                                Color(0.0, 0.478, 1.0, 1.0),
1070                            );
1071                        });
1072                    },
1073                );
1074            });
1075        };
1076
1077        composition.render(key, &mut content).expect("render");
1078        let root = composition.root().expect("root");
1079        let handle = composition.runtime_handle();
1080        let mut scene = None;
1081        for _ in 0..8 {
1082            for _ in 0..16 {
1083                if !composition.should_render() {
1084                    break;
1085                }
1086                composition.reconcile(key, &mut content).expect("reconcile");
1087            }
1088            let mut applier = composition.applier_mut();
1089            applier.set_runtime_handle(handle.clone());
1090            let layout = applier
1091                .compute_layout(
1092                    root,
1093                    Size {
1094                        width: 400.0,
1095                        height: 400.0,
1096                    },
1097                )
1098                .expect("layout");
1099            applier.clear_runtime_handle();
1100            drop(applier);
1101            scene = Some(HeadlessRenderer::new().render(&layout));
1102        }
1103        scene.expect("scene")
1104    }
1105
1106    #[test]
1107    fn field_window_origin_follows_vertical_scroll() {
1108        use std::cell::RefCell;
1109
1110        use cranpose_core::{Key, remember};
1111        use cranpose_foundation::modifier_element;
1112        use cranpose_ui_graphics::Size;
1113
1114        use crate::{
1115            layout::{LayoutBox, LayoutEngine, policies::EmptyMeasurePolicy},
1116            renderer::HeadlessRenderer,
1117            scroll::ScrollState,
1118            widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer},
1119        };
1120
1121        let _app_context = crate::render_state::app_context_test_scope();
1122
1123        let mut composition = Composition::new(MemoryApplier::new());
1124        let state = TextFieldState::new("hello world");
1125        let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
1126            Rc::new(RefCell::new(None));
1127        let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1128
1129        let spacer_before = 200.0_f32;
1130        let mut content = {
1131            let controller_slot = Rc::clone(&controller_slot);
1132            let scroll_slot = Rc::clone(&scroll_slot);
1133            move || {
1134                let controller_slot = Rc::clone(&controller_slot);
1135                let scroll_slot = Rc::clone(&scroll_slot);
1136                PopupHost(move || {
1137                    let controller = remember(TextFieldHandleController::new)
1138                        .with(TextFieldHandleController::clone);
1139                    *controller_slot.borrow_mut() = Some(controller.clone());
1140                    let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
1141                    *scroll_slot.borrow_mut() = Some(scroll);
1142                    let state = state;
1143                    let controller = controller.clone();
1144                    Column(
1145                        Modifier::empty()
1146                            .size(Size {
1147                                width: 300.0,
1148                                height: 150.0,
1149                            })
1150                            .vertical_scroll(scroll, false),
1151                        ColumnSpec::default(),
1152                        move || {
1153                            Spacer(Size {
1154                                width: 300.0,
1155                                height: spacer_before,
1156                            });
1157                            let element = TextFieldElement::new(state, TextStyle::default())
1158                                .with_handle_controller(controller.clone());
1159                            let field_modifier =
1160                                Modifier::from_parts(vec![modifier_element(element)]);
1161                            Layout(field_modifier, EmptyMeasurePolicy, || {});
1162                            Spacer(Size {
1163                                width: 300.0,
1164                                height: 400.0,
1165                            });
1166                        },
1167                    );
1168                });
1169            }
1170        };
1171
1172        fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
1173            if node
1174                .node_data
1175                .modifier_slices()
1176                .text_field_window_origin()
1177                .is_some()
1178            {
1179                return Some(node.rect);
1180            }
1181            node.children.iter().find_map(find_field_rect)
1182        }
1183
1184        fn layout_and_read(
1185            composition: &mut Composition<MemoryApplier>,
1186            key: Key,
1187            content: &mut dyn FnMut(),
1188            controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
1189        ) -> (Point, f32) {
1190            for _ in 0..16 {
1191                if !composition.should_render() {
1192                    break;
1193                }
1194                composition
1195                    .reconcile(key, &mut *content)
1196                    .expect("reconcile");
1197            }
1198            let root = composition.root().expect("root");
1199            let handle = composition.runtime_handle();
1200            let mut applier = composition.applier_mut();
1201            applier.set_runtime_handle(handle);
1202            let layout = applier
1203                .compute_layout(
1204                    root,
1205                    cranpose_ui_graphics::Size {
1206                        width: 400.0,
1207                        height: 600.0,
1208                    },
1209                )
1210                .expect("layout");
1211            applier.clear_runtime_handle();
1212            drop(applier);
1213            let _ = HeadlessRenderer::new().render(&layout);
1214            let field_y = find_field_rect(layout.root()).expect("field placed").y;
1215            let node_origin = controller_slot
1216                .borrow()
1217                .as_ref()
1218                .expect("controller")
1219                .metrics()
1220                .expect("metrics published")
1221                .node_origin;
1222            (node_origin, field_y)
1223        }
1224
1225        let key = location_key(file!(), line!(), column!());
1226        composition.render(key, &mut content).expect("render");
1227
1228        let (origin0, field_y0) =
1229            layout_and_read(&mut composition, key, &mut content, &controller_slot);
1230        assert!(
1231            (origin0.y - field_y0).abs() < 0.5,
1232            "published node_origin.y {} must equal the field's placed window-y {}",
1233            origin0.y,
1234            field_y0
1235        );
1236        assert!(
1237            origin0.y >= spacer_before - 0.5,
1238            "field should start at/after the {spacer_before}px leading spacer, got {}",
1239            origin0.y
1240        );
1241
1242        let scroll = *scroll_slot.borrow().as_ref().expect("scroll state");
1243        scroll.scroll_to(50.0);
1244        assert!(
1245            scroll.value() >= 49.5,
1246            "test setup: content must be tall enough to scroll 50px (got {})",
1247            scroll.value()
1248        );
1249        let (origin1, field_y1) =
1250            layout_and_read(&mut composition, key, &mut content, &controller_slot);
1251        assert!(
1252            (origin1.y - field_y1).abs() < 0.5,
1253            "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
1254            origin1.y,
1255            field_y1
1256        );
1257        assert!(
1258            (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
1259            "scrolling 50px must shift the published field origin up by 50px: \
1260             before {}, after {} (expected {})",
1261            origin0.y,
1262            origin1.y,
1263            origin0.y - 50.0
1264        );
1265    }
1266
1267    #[test]
1268    fn window_offset_roundtrip_holds_under_scroll_offset() {
1269        let _app_context = crate::render_state::app_context_test_scope();
1270        let text = "hello world";
1271        let style = TextStyle::default();
1272        for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
1273            let metrics = TextFieldHandleMetrics {
1274                focused: true,
1275                direct_manipulation: true,
1276                node_origin,
1277                padding_left: 4.0,
1278                padding_top: 3.0,
1279                scroll_offset: 0.0,
1280                line_height: 18.0,
1281                glyph_box: (0.0, 18.0),
1282                wrap_width: None,
1283            };
1284            for offset in 0..=text.len() {
1285                if !text.is_char_boundary(offset) {
1286                    continue;
1287                }
1288                let tip =
1289                    handle_tip_window_pos(text, &style, &metrics, offset, LineAffinity::Downstream);
1290                let resolved = window_pos_to_offset(text, &style, &metrics, tip, 0.0);
1291                assert_eq!(
1292                    resolved, offset,
1293                    "finger at the tip of offset {offset} must map back to it \
1294                     under origin {node_origin:?}, got {resolved}"
1295                );
1296            }
1297        }
1298    }
1299
1300    #[test]
1301    fn shared_wrap_boundary_anchors_by_handle_affinity() {
1302        let _app_context = crate::render_state::app_context_test_scope();
1303        with_test_runtime(|| {
1304            let text = "aaaaaaaaaaaaaaaaaaaaaaaa";
1305            let style = TextStyle::default();
1306            let annotated = crate::text::AnnotatedString::from(text);
1307            let full = crate::text::measure_text(&annotated, &style);
1308            let wrap_width = full.width / 3.0;
1309            let ranges = crate::text::wrapped_line_ranges(
1310                None,
1311                &annotated,
1312                &style,
1313                crate::text::TextLayoutOptions::default(),
1314                Some(wrap_width),
1315            );
1316            assert!(
1317                ranges.len() >= 2,
1318                "test setup: text must wrap, got {ranges:?}"
1319            );
1320            let boundary = ranges[1].start;
1321            assert_eq!(
1322                ranges[0].end, boundary,
1323                "test setup: a mid-word wrap must share its boundary byte, got {ranges:?}"
1324            );
1325
1326            let line_height = 20.0;
1327            let metrics = TextFieldHandleMetrics {
1328                focused: true,
1329                direct_manipulation: true,
1330                node_origin: Point { x: 0.0, y: 0.0 },
1331                padding_left: 0.0,
1332                padding_top: 0.0,
1333                scroll_offset: 0.0,
1334                line_height,
1335                glyph_box: (0.0, line_height),
1336                wrap_width: Some(wrap_width),
1337            };
1338
1339            let end_tip =
1340                handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Upstream);
1341            assert!(
1342                (end_tip.y - line_height).abs() < 0.5,
1343                "end handle must sit on the UPPER line's bottom ({line_height}), got y={}",
1344                end_tip.y
1345            );
1346            assert!(
1347                end_tip.x > 1.0,
1348                "end handle must sit at the upper line's right edge, got x={}",
1349                end_tip.x
1350            );
1351
1352            let start_tip =
1353                handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Downstream);
1354            assert!(
1355                (start_tip.y - 2.0 * line_height).abs() < 0.5,
1356                "start handle must sit on the LOWER line's bottom ({}), got y={}",
1357                2.0 * line_height,
1358                start_tip.y
1359            );
1360            assert!(
1361                start_tip.x.abs() < 0.5,
1362                "start handle must sit at the lower line's left edge, got x={}",
1363                start_tip.x
1364            );
1365
1366            let mut grab = HandleGrabOffset::begin(end_tip.y, end_tip.y);
1367            for finger_y in [
1368                end_tip.y,
1369                end_tip.y + 8.0,
1370                end_tip.y + 32.0,
1371                end_tip.y + 80.0,
1372            ] {
1373                let bias = grab.track(finger_y);
1374                let resolved = window_pos_to_offset(
1375                    text,
1376                    &style,
1377                    &metrics,
1378                    Point {
1379                        x: end_tip.x,
1380                        y: finger_y,
1381                    },
1382                    bias,
1383                );
1384                let resolved_tip =
1385                    handle_tip_window_pos(text, &style, &metrics, resolved, LineAffinity::Upstream);
1386                let target_tip_y =
1387                    (finger_y + bias).clamp(line_height, ranges.len() as f32 * line_height);
1388                assert!(
1389                    (resolved_tip.y - target_tip_y).abs() <= line_height * 0.5 + 0.5,
1390                    "finger y={finger_y}, bias={bias} resolved to offset {resolved} at y={}, expected the nearest visual-line bottom to {}",
1391                    resolved_tip.y,
1392                    target_tip_y,
1393                );
1394            }
1395        })
1396    }
1397
1398    fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
1399        use crate::renderer::RenderOp;
1400        scene
1401            .operations()
1402            .iter()
1403            .filter_map(|op| match op {
1404                RenderOp::Text { value, .. } => Some(value.clone()),
1405                _ => None,
1406            })
1407            .collect()
1408    }
1409
1410    fn render_caret_action_menu(
1411        can_paste: bool,
1412        can_undo: bool,
1413        can_redo: bool,
1414    ) -> crate::renderer::RecordedRenderScene {
1415        use cranpose_ui_graphics::Size;
1416
1417        use crate::{layout::LayoutEngine, renderer::HeadlessRenderer, widgets::PopupHost};
1418
1419        let mut composition = Composition::new(MemoryApplier::new());
1420        let key = location_key(file!(), line!(), column!());
1421
1422        let mut content = move || {
1423            PopupHost(move || {
1424                CaretActionMenu(
1425                    40.0,
1426                    60.0,
1427                    true,
1428                    can_paste,
1429                    can_undo,
1430                    can_redo,
1431                    || {},
1432                    || {},
1433                    || {},
1434                    || {},
1435                );
1436            });
1437        };
1438
1439        composition.render(key, &mut content).expect("render");
1440        for _ in 0..16 {
1441            if !composition.should_render() {
1442                break;
1443            }
1444            composition.reconcile(key, &mut content).expect("reconcile");
1445        }
1446        let root = composition.root().expect("root");
1447        let handle = composition.runtime_handle();
1448        let mut applier = composition.applier_mut();
1449        applier.set_runtime_handle(handle);
1450        let layout = applier
1451            .compute_layout(
1452                root,
1453                Size {
1454                    width: 400.0,
1455                    height: 400.0,
1456                },
1457            )
1458            .expect("layout");
1459        applier.clear_runtime_handle();
1460        drop(applier);
1461        HeadlessRenderer::new().render(&layout)
1462    }
1463
1464    #[test]
1465    fn caret_action_menu_shows_paste_select_all_undo_redo() {
1466        let _app_context = crate::render_state::app_context_test_scope();
1467
1468        let all = text_values(&render_caret_action_menu(true, true, true));
1469        for label in ["Paste", "Select all", "Undo", "Redo"] {
1470            assert!(
1471                all.iter().any(|t| t == label),
1472                "caret menu should show {label:?}, got {all:?}"
1473            );
1474        }
1475
1476        let bare = text_values(&render_caret_action_menu(false, false, false));
1477        assert!(
1478            bare.iter().any(|t| t == "Select all"),
1479            "Select all is always available, got {bare:?}"
1480        );
1481        assert!(
1482            !bare
1483                .iter()
1484                .any(|t| t == "Paste" || t == "Undo" || t == "Redo"),
1485            "Paste/Undo/Redo must be hidden when unavailable, got {bare:?}"
1486        );
1487    }
1488
1489    #[test]
1490    fn context_menu_shows_for_pointer_selection_on_every_platform() {
1491        let _app_context = crate::render_state::app_context_test_scope();
1492
1493        let touch = text_values(&render_range_menu(true));
1494        assert!(
1495            touch.iter().any(|t| t == "Copy"),
1496            "touch selection should show the Copy menu item, got {touch:?}"
1497        );
1498        assert!(
1499            touch.iter().any(|t| t == "Cut"),
1500            "expected Cut, got {touch:?}"
1501        );
1502        assert!(
1503            touch.iter().any(|t| t == "Select all"),
1504            "expected Select all, got {touch:?}"
1505        );
1506
1507        let mouse = text_values(&render_range_menu(true));
1508        assert!(
1509            mouse.iter().any(|t| t == "Copy"),
1510            "mouse selection must expose the same direct-manipulation menu, got {mouse:?}"
1511        );
1512        let keyboard = text_values(&render_range_menu(false));
1513        assert!(
1514            !keyboard.iter().any(|t| t == "Copy"),
1515            "keyboard-only focus must keep a clean caret, got {keyboard:?}"
1516        );
1517    }
1518
1519    #[test]
1520    fn selection_handles_and_menu_survive_subcomposition() {
1521        let _app_context = crate::render_state::app_context_test_scope();
1522        let scene = render_range_menu_subcomposed(true);
1523
1524        let texts = text_values(&scene);
1525        assert!(
1526            texts.iter().any(|t| t == "Copy"),
1527            "a touch selection inside a subcomposition should show the Copy menu \
1528             item through the host, got {texts:?}"
1529        );
1530        assert!(
1531            texts.iter().any(|t| t == "Select all"),
1532            "expected Select all inside a subcomposition, got {texts:?}"
1533        );
1534        assert_eq!(
1535            image_count(&scene),
1536            2,
1537            "a touch range selection should show two finger teardrop handles in \
1538             the overlay across the subcomposition boundary"
1539        );
1540    }
1541
1542    #[test]
1543    fn selection_handles_and_menu_survive_lazy_column_item() {
1544        let _app_context = crate::render_state::app_context_test_scope();
1545        let scene = render_range_menu_lazy_column(true);
1546
1547        let texts = text_values(&scene);
1548        assert!(
1549            texts.iter().any(|t| t == "Copy"),
1550            "a touch selection inside a LazyColumn item should show the Copy menu \
1551             item through the host, got {texts:?}"
1552        );
1553        assert!(
1554            texts.iter().any(|t| t == "Select all"),
1555            "expected Select all inside a LazyColumn item, got {texts:?}"
1556        );
1557        assert_eq!(
1558            image_count(&scene),
1559            2,
1560            "a touch range selection should show two finger teardrop handles in \
1561             the overlay across the LazyColumn item subcomposition boundary"
1562        );
1563    }
1564
1565    fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
1566        use cranpose_ui_graphics::DrawPrimitive;
1567
1568        use crate::renderer::RenderOp;
1569        scene
1570            .operations()
1571            .iter()
1572            .filter(|op| {
1573                matches!(
1574                    op,
1575                    RenderOp::Primitive {
1576                        primitive: DrawPrimitive::Image { .. },
1577                        ..
1578                    }
1579                )
1580            })
1581            .count()
1582    }
1583
1584    #[test]
1585    fn cursor_handle_shows_for_pointer_selection_on_every_platform() {
1586        let _app_context = crate::render_state::app_context_test_scope();
1587        assert_eq!(
1588            image_count(&render_collapsed_handles(true)),
1589            1,
1590            "a touch caret should show one finger cursor handle in the overlay"
1591        );
1592        assert_eq!(
1593            image_count(&render_collapsed_handles(true)),
1594            1,
1595            "a mouse-created caret should expose its draggable handle"
1596        );
1597        assert_eq!(
1598            image_count(&render_collapsed_handles(false)),
1599            0,
1600            "keyboard-only focus should keep a clean caret"
1601        );
1602    }
1603
1604    #[test]
1605    fn basic_text_field_creates_node() {
1606        let _app_context = crate::render_state::app_context_test_scope();
1607        let mut composition = Composition::new(MemoryApplier::new());
1608        let state = TextFieldState::new("Test content");
1609
1610        let result = composition.render(location_key(file!(), line!(), column!()), move || {
1611            BasicTextField(state, Modifier::empty(), TextStyle::default());
1612        });
1613
1614        assert!(result.is_ok());
1615        assert!(composition.root().is_some());
1616    }
1617
1618    #[test]
1619    fn basic_text_field_state_updates() {
1620        let _app_context = crate::render_state::app_context_test_scope();
1621        with_test_runtime(|| {
1622            let state = TextFieldState::new("Hello");
1623            assert_eq!(state.text(), "Hello");
1624
1625            state.edit(|buffer| {
1626                buffer.place_cursor_at_end();
1627                buffer.insert("!");
1628            });
1629
1630            assert_eq!(state.text(), "Hello!");
1631        });
1632    }
1633
1634    #[test]
1635    fn lazy_column_responder_scrolls_a_hidden_caret_into_view() {
1636        use std::cell::RefCell;
1637
1638        use cranpose_core::Key;
1639        use cranpose_foundation::lazy::{LazyListScope, LazyListState, rememberLazyListState};
1640        use cranpose_ui_graphics::Size;
1641
1642        use crate::{
1643            LazyColumn, LazyColumnSpec,
1644            bring_into_view::local_bring_into_view_responder,
1645            layout::LayoutEngine,
1646            renderer::HeadlessRenderer,
1647            widgets::{Box, BoxSpec, PopupHost},
1648        };
1649
1650        let _app_context = crate::render_state::app_context_test_scope();
1651        let mut composition = Composition::new(MemoryApplier::new());
1652        let responder_slot: Rc<RefCell<Option<crate::bring_into_view::BringIntoViewResponder>>> =
1653            Rc::new(RefCell::new(None));
1654        let state_slot: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1655
1656        let mut content = {
1657            let responder_slot = Rc::clone(&responder_slot);
1658            let state_slot = Rc::clone(&state_slot);
1659            move || {
1660                let responder_slot = Rc::clone(&responder_slot);
1661                let state_slot = Rc::clone(&state_slot);
1662                PopupHost(move || {
1663                    let list_state = rememberLazyListState();
1664                    *state_slot.borrow_mut() = Some(list_state);
1665                    let responder_slot = Rc::clone(&responder_slot);
1666                    LazyColumn(
1667                        Modifier::empty().size(Size {
1668                            width: 300.0,
1669                            height: 400.0,
1670                        }),
1671                        list_state,
1672                        LazyColumnSpec::default(),
1673                        move |scope| {
1674                            let responder_slot = Rc::clone(&responder_slot);
1675                            scope.items(30, move |_index| {
1676                                if responder_slot.borrow().is_none()
1677                                    && let Some(r) = local_bring_into_view_responder().current()
1678                                {
1679                                    *responder_slot.borrow_mut() = Some(r);
1680                                }
1681                                Box(
1682                                    Modifier::empty().size(Size {
1683                                        width: 300.0,
1684                                        height: 80.0,
1685                                    }),
1686                                    BoxSpec::default(),
1687                                    || {},
1688                                );
1689                            });
1690                        },
1691                    );
1692                });
1693            }
1694        };
1695
1696        fn run_layout(
1697            composition: &mut Composition<MemoryApplier>,
1698            key: Key,
1699            content: &mut dyn FnMut(),
1700        ) {
1701            for _ in 0..16 {
1702                if !composition.should_render() {
1703                    break;
1704                }
1705                composition
1706                    .reconcile(key, &mut *content)
1707                    .expect("reconcile");
1708            }
1709            let root = composition.root().expect("root");
1710            let handle = composition.runtime_handle();
1711            let mut applier = composition.applier_mut();
1712            applier.set_runtime_handle(handle);
1713            let layout = applier
1714                .compute_layout(
1715                    root,
1716                    Size {
1717                        width: 400.0,
1718                        height: 600.0,
1719                    },
1720                )
1721                .expect("layout");
1722            applier.clear_runtime_handle();
1723            drop(applier);
1724            let _ = HeadlessRenderer::new().render(&layout);
1725        }
1726
1727        let key = location_key(file!(), line!(), column!());
1728        composition.render(key, &mut content).expect("render");
1729        run_layout(&mut composition, key, &mut content);
1730
1731        let responder = responder_slot
1732            .borrow()
1733            .clone()
1734            .expect("LazyColumn provides a bring-into-view responder to its items");
1735        let list_state = state_slot.borrow().expect("list state captured");
1736        let offset0 = list_state.first_visible_item_scroll_offset();
1737        let index0 = list_state.first_visible_item_index();
1738
1739        responder.bring_into_view(
1740            Rect {
1741                x: 10.0,
1742                y: 100.0,
1743                width: 2.0,
1744                height: 20.0,
1745            },
1746            0.0,
1747        );
1748        run_layout(&mut composition, key, &mut content);
1749        assert_eq!(
1750            list_state.first_visible_item_index(),
1751            index0,
1752            "an already-visible caret must not scroll the list"
1753        );
1754        assert!(
1755            (list_state.first_visible_item_scroll_offset() - offset0).abs() < 0.5,
1756            "an already-visible caret must not scroll the list"
1757        );
1758
1759        responder.bring_into_view(
1760            Rect {
1761                x: 10.0,
1762                y: 360.0,
1763                width: 2.0,
1764                height: 20.0,
1765            },
1766            250.0,
1767        );
1768        run_layout(&mut composition, key, &mut content);
1769        let scrolled_forward = list_state.first_visible_item_index() > index0
1770            || list_state.first_visible_item_scroll_offset() > offset0 + 0.5;
1771        assert!(
1772            scrolled_forward,
1773            "a caret behind the keyboard must scroll the list forward \
1774             (index {} -> {}, offset {:.1} -> {:.1})",
1775            index0,
1776            list_state.first_visible_item_index(),
1777            offset0,
1778            list_state.first_visible_item_scroll_offset(),
1779        );
1780    }
1781}