Skip to main content

cranpose_ui/widgets/
basic_text_field.rs

1//! BasicTextField widget for editable text input.
2//!
3//! This module provides the `BasicTextField` composable following Jetpack Compose's
4//! `BasicTextField` pattern from `compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt`.
5
6use std::{
7    cell::{Cell, RefCell},
8    rc::{Rc, Weak},
9};
10
11use cranpose_core::{MutableState, NodeId, SideEffect, mutableStateOf, remember};
12use cranpose_foundation::{
13    modifier_element,
14    text::{TextFieldLineLimits, TextFieldState, TextRange},
15};
16use cranpose_ui_graphics::{Color, Point, Rect};
17
18use crate::{
19    bring_into_view::local_bring_into_view_responder,
20    clipboard_session::{clipboard_can_paste, clipboard_paste_into_focus, clipboard_write_text},
21    composable,
22    layout::{
23        core::Alignment,
24        policies::{BoxMeasurePolicy, EmptyMeasurePolicy},
25    },
26    modifier::Modifier,
27    safe_area::local_ime_insets,
28    text::{AnnotatedString, TextStyle, measure_text},
29    text_field_decorator_node::TextFieldDecoratorElement,
30    text_field_focus::{dispatch_copy, dispatch_cut, dispatch_select_all},
31    text_field_modifier_node::{
32        TextFieldElement, TextFieldHandleController, TextFieldHandleMetrics, TextFieldRefs,
33    },
34    text_selection::{
35        HANDLE_RADIUS, HandleGrabOffset, HandleKind, LineAffinity, selection_after_handle_drag,
36    },
37    widgets::{
38        CaretActionMenu, Layout, MenuAnchor, SelectionHandle, SelectionLoupe, TextSelectionMenu,
39        loupe_target_for_drag,
40    },
41};
42
43/// Alpha of the selection highlight relative to the field's accent
44/// (`TextFieldOptions::cursor_color`): the reference highlight is the tint
45/// at ~0.32 opacity, while the caret and both selection handles carry it
46/// solid — one accent drives all three.
47pub const SELECTION_HIGHLIGHT_ALPHA: f32 = 0.32;
48
49/// Hold duration before a stationary touch press on the text claims the
50/// gesture (word-select + menu while the finger is still down).
51const TEXT_LONG_PRESS_MS: u64 = 500;
52/// Travel beyond this (dp) before the hold elapses is a drag, not a
53/// long-press.
54const TEXT_LONG_PRESS_SLOP: f32 = 12.0;
55
56/// Frame-clock watcher for the long-press → slide-to-menu gesture: armed by
57/// the composition when the field node publishes a fresh touch press, it
58/// claims the gesture after the hold threshold (selecting the word under
59/// the press; the range-change side effect opens the menu). The composition
60/// slot holds the only strong reference — dropping it (press ended, field
61/// recomposed away) cancels the pending frame callback.
62struct LongPressWatcher {
63    controller: TextFieldHandleController,
64    state: TextFieldState,
65    style: TextStyle,
66    start: Point,
67    start_nanos: Cell<Option<u64>>,
68    registration: RefCell<Option<cranpose_core::internal::FrameCallbackRegistration>>,
69    frame_clock: cranpose_core::internal::FrameClock,
70}
71
72impl LongPressWatcher {
73    fn arm(self: &Rc<Self>) {
74        let weak: Weak<LongPressWatcher> = Rc::downgrade(self);
75        let registration = self.frame_clock.with_frame_nanos(move |now| {
76            let Some(watcher) = weak.upgrade() else {
77                return;
78            };
79            watcher.tick(now);
80        });
81        *self.registration.borrow_mut() = Some(registration);
82        crate::request_render_invalidation();
83    }
84
85    fn tick(self: Rc<Self>, now: u64) {
86        self.registration.borrow_mut().take();
87        let Some(press) = self.controller.press() else {
88            return;
89        };
90        let moved = (press.position.x - self.start.x)
91            .abs()
92            .max((press.position.y - self.start.y).abs());
93        if (press.start.x - self.start.x).abs() > 0.5
94            || (press.start.y - self.start.y).abs() > 0.5
95            || moved > TEXT_LONG_PRESS_SLOP
96        {
97            return;
98        }
99        let start = match self.start_nanos.get() {
100            Some(value) => value,
101            None => {
102                self.start_nanos.set(Some(now));
103                now
104            }
105        };
106        if now.saturating_sub(start) < TEXT_LONG_PRESS_MS * 1_000_000 {
107            self.arm();
108            return;
109        }
110        self.controller.claim_gesture();
111        let Some(metrics) = self.controller.metrics() else {
112            return;
113        };
114        let text = self.state.text();
115        let offset = window_pos_to_offset(&text, &self.style, &metrics, self.start, 0.0);
116        let (word_start, word_end) = crate::word_boundaries::find_word_boundaries(&text, offset);
117        self.state.edit(|buffer| {
118            buffer.select(TextRange::new(word_start, word_end));
119        });
120        crate::request_render_invalidation();
121    }
122}
123
124/// Local position where a handle's tip should sit for the caret/selection
125/// endpoint at byte `offset`: the bottom of that offset's visual line.
126/// `affinity` decides the line at a shared soft-wrap boundary: selection ENDS,
127/// the cursor handle and the loupe anchor upstream (the line the finger rides),
128/// the selection START anchors downstream (the first highlighted glyph).
129fn handle_tip_local_pos(
130    text: &str,
131    style: &TextStyle,
132    metrics: &TextFieldHandleMetrics,
133    offset: usize,
134    affinity: LineAffinity,
135) -> Point {
136    let offset = offset.min(text.len());
137    let (line_index, line_start) = crate::text_field_modifier_node::caret_visual_line_for_offset(
138        text,
139        style,
140        None,
141        metrics.wrap_width,
142        offset,
143        affinity,
144    );
145    let caret_x = measure_text(&AnnotatedString::from(&text[line_start..offset]), style).width;
146    Point {
147        x: metrics.padding_left + caret_x - metrics.scroll_offset,
148        y: metrics.padding_top
149            + line_index as f32 * metrics.line_height
150            + metrics.glyph_box.0
151            + metrics.glyph_box.1,
152    }
153}
154
155fn caret_window_geometry(
156    text: &str,
157    style: &TextStyle,
158    metrics: &TextFieldHandleMetrics,
159    offset: usize,
160    affinity: LineAffinity,
161) -> (Point, Rect) {
162    let tip = handle_tip_local_pos(text, style, metrics, offset, affinity);
163    let bounds = metrics.local_to_window.bounds_for_rect(Rect {
164        x: tip.x,
165        y: tip.y - metrics.glyph_box.1,
166        width: 2.0,
167        height: metrics.glyph_box.1,
168    });
169    (metrics.local_to_window.map_point(tip), bounds)
170}
171
172/// Maps a window-space drag position back to the nearest text byte offset in
173/// the field. `y_bias` is the finger-to-line offset captured when the handle
174/// was grabbed (`grab line bottom − finger y`): adding it back keeps the drag
175/// targeting the line the finger means, whether the grab was on the line
176/// itself (stem/edge) or on the dot hanging outside it — the reference drags
177/// preserve the initial finger-to-line relationship.
178fn window_pos_to_offset(
179    text: &str,
180    style: &TextStyle,
181    metrics: &TextFieldHandleMetrics,
182    window_pos: Point,
183    y_bias: f32,
184) -> usize {
185    let Some(inverse) = metrics.local_to_window.inverse() else {
186        return 0;
187    };
188    let local = inverse.map_point(Point {
189        y: window_pos.y + y_bias,
190        ..window_pos
191    });
192    let local_x = (local.x - metrics.padding_left + metrics.scroll_offset).max(0.0);
193    let local_y = (local.y - 0.5 * metrics.line_height - metrics.padding_top).max(0.0);
194    crate::text::offset_for_position_wrapped(
195        text,
196        style,
197        None,
198        metrics.wrap_width,
199        metrics.line_height,
200        local_x,
201        local_y,
202    )
203}
204///
205/// # When to use
206/// Use this when you need an editable text input but want full control over the
207/// styling (no built-in borders or labels).
208///
209/// # Arguments
210///
211/// * `state` - The observable text field state that holds text content and cursor position.
212/// * `modifier` - Modifiers for styling and layout.
213/// * `style` - Text styling (color, font size).
214///
215/// # Example
216///
217/// ```rust,ignore
218/// let text = remember_text_field_state("Initial text");
219/// BasicTextField(text, Modifier::padding(8.0), TextStyle::default());
220/// ```
221#[composable]
222pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
223    BasicTextFieldWithOptions(
224        state,
225        modifier,
226        BasicTextFieldOptions {
227            text_style: style,
228            ..BasicTextFieldOptions::default()
229        },
230    )
231}
232
233/// Options for customizing BasicTextField appearance and behavior.
234#[derive(Debug, Clone, PartialEq)]
235pub struct BasicTextFieldOptions {
236    /// Text style
237    pub text_style: TextStyle,
238    /// Cursor color
239    pub cursor_color: Color,
240    /// Line limits: SingleLine or MultiLine with optional min/max
241    pub line_limits: TextFieldLineLimits,
242    /// Whether gaining focus opens the software keyboard. Defaults to `true`.
243    ///
244    /// When `false`, programmatic and keyboard-navigation focus leave the
245    /// software keyboard hidden. A pointer tap still explicitly opens it.
246    /// Changing this to `true` while focused also requests the keyboard.
247    /// Changing it to `false` preserves an already visible keyboard.
248    pub show_keyboard_on_focus: bool,
249}
250
251impl Default for BasicTextFieldOptions {
252    fn default() -> Self {
253        Self {
254            text_style: TextStyle::default(),
255            cursor_color: Color(0.0, 0.478, 1.0, 1.0),
256            line_limits: TextFieldLineLimits::default(),
257            show_keyboard_on_focus: true,
258        }
259    }
260}
261
262/// Scope passed to a basic text field decoration box.
263///
264/// The decoration may place arbitrary composables around the editable content,
265/// but must call [`inner_text_field`](Self::inner_text_field) exactly once.
266#[derive(Clone)]
267pub struct BasicTextFieldDecorationScope {
268    inner: Rc<dyn Fn() -> NodeId>,
269}
270
271impl BasicTextFieldDecorationScope {
272    pub fn inner_text_field(&self) -> NodeId {
273        (self.inner)()
274    }
275}
276
277impl PartialEq for BasicTextFieldDecorationScope {
278    fn eq(&self, other: &Self) -> bool {
279        Rc::ptr_eq(&self.inner, &other.inner)
280    }
281}
282
283/// Creates an editable field and lets `decoration_box` place composable labels,
284/// placeholders, icons, buttons, prefixes, or suffixes around it.
285///
286/// As in Compose, the decoration box is the field: `modifier` applies to it,
287/// and it takes the field's pointer input, focus and semantics, so a press
288/// anywhere in it edits the text. The inner field only lays out and draws
289/// the text, as wide as the text unless the decoration stretches it (a
290/// `Box` that propagates its minimum constraints, for one).
291#[composable(no_skip)]
292pub fn BasicTextFieldDecorated<D>(
293    state: TextFieldState,
294    modifier: Modifier,
295    options: BasicTextFieldOptions,
296    decoration_box: D,
297) -> NodeId
298where
299    D: Fn(BasicTextFieldDecorationScope) + 'static,
300{
301    let refs = remember(TextFieldRefs::new).with(TextFieldRefs::clone);
302    let decorator = TextFieldDecoratorElement::new(
303        state,
304        refs.clone(),
305        options.text_style.clone(),
306        options.line_limits,
307        crate::modal::local_modal_depth().current(),
308    );
309    let scope = BasicTextFieldDecorationScope {
310        inner: Rc::new(move || {
311            TextFieldNode(
312                state,
313                Modifier::empty(),
314                options.clone(),
315                Some(refs.clone()),
316            )
317        }),
318    };
319    Layout(
320        modifier.then(Modifier::from_parts(&[modifier_element(decorator)])),
321        BoxMeasurePolicy::new(Alignment::TOP_START, true),
322        move || decoration_box(scope.clone()),
323    )
324}
325
326/// Creates an editable text field with custom options.
327///
328/// This is the full version of `BasicTextField` with all configuration options.
329#[composable]
330pub fn BasicTextFieldWithOptions(
331    state: TextFieldState,
332    modifier: Modifier,
333    options: BasicTextFieldOptions,
334) -> NodeId {
335    TextFieldNode(state, modifier, options, None)
336}
337
338/// The field's node, and the handles and caret tracking around it. With
339/// `decorator`, the decoration box sharing those refs takes the field's
340/// input, focus and semantics.
341#[composable]
342fn TextFieldNode(
343    state: TextFieldState,
344    modifier: Modifier,
345    options: BasicTextFieldOptions,
346    decorator: Option<TextFieldRefs>,
347) -> NodeId {
348    let _text = state.text();
349    let _selection = state.selection();
350
351    let controller =
352        remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
353
354    let modal_depth = crate::modal::local_modal_depth().current();
355    let mut text_field_element = TextFieldElement::new(state, options.text_style.clone())
356        .with_cursor_color(options.cursor_color)
357        .with_line_limits(options.line_limits)
358        .with_show_keyboard_on_focus(options.show_keyboard_on_focus)
359        .with_handle_controller(controller.clone())
360        .with_modal_depth(modal_depth);
361    if let Some(refs) = decorator {
362        text_field_element = text_field_element.decorated_by(refs);
363    }
364
365    let text_field_modifier = modifier_element(text_field_element);
366    let final_modifier = Modifier::from_parts(&[text_field_modifier]);
367    let combined_modifier = modifier.then(final_modifier);
368
369    let node = Layout(combined_modifier, EmptyMeasurePolicy, || {});
370
371    BringCaretIntoView(state, options.text_style.clone(), controller.clone());
372
373    SelectionHandles(state, options.text_style, controller, options.cursor_color);
374
375    node
376}
377
378#[cfg(test)]
379#[path = "tests/basic_text_field_options_tests.rs"]
380mod options_tests;
381
382/// Window-space rect of the field's caret (the cursor line at byte `offset`),
383/// derived from the field's published handle [`TextFieldHandleMetrics`]. Its top
384/// is the top of the caret's visual line; its height is one line.
385fn caret_window_rect(
386    text: &str,
387    style: &TextStyle,
388    metrics: &TextFieldHandleMetrics,
389    offset: usize,
390) -> Rect {
391    caret_window_geometry(text, style, metrics, offset, LineAffinity::Upstream).1
392}
393
394/// Consumer half of bug 2: while the field is focused, asks the nearest scroll
395/// container (via [`local_bring_into_view_responder`]) to scroll the caret clear
396/// of the on-screen keyboard ([`local_ime_insets`]).
397///
398/// The request is triggered only by focus, caret movement, or a change in the
399/// keyboard inset — never by scrolling — so the user is never yanked back while
400/// deliberately scrolling the field out of view. The caret rect handed to the
401/// responder is always recomputed from the live metrics, so the scroll delta is
402/// correct even as the keyboard animates in.
403#[composable]
404fn BringCaretIntoView(
405    state: TextFieldState,
406    style: TextStyle,
407    controller: TextFieldHandleController,
408) {
409    let Some(metrics) = controller.metrics() else {
410        return;
411    };
412    let ime_bottom = local_ime_insets().current().bottom;
413    let responder = local_bring_into_view_responder().current();
414
415    let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
416        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
417
418    if !metrics.focused {
419        previous.set(None);
420        return;
421    }
422    let Some(responder) = responder else {
423        return;
424    };
425
426    let text = state.text();
427    let selection = state.selection();
428
429    let key = (
430        selection.start,
431        selection.end,
432        (ime_bottom * 4.0).round() as i64,
433    );
434    SideEffect(move || {
435        if previous.get() == Some(key) {
436            return;
437        }
438        previous.set(Some(key));
439        let Some(metrics) = controller.metrics_now() else {
440            return;
441        };
442        let caret = caret_window_rect(&text, &style, &metrics, selection.start);
443        responder.bring_into_view(caret, ime_bottom);
444    });
445}
446
447/// Emits selection/cursor handles for a focused field entered through any
448/// primary pointer. Keyboard-only focus keeps a clean caret.
449/// `accent` is the field's tint (its cursor color): handles are drawn solid in
450/// it, matching the caret and the highlight derived from it.
451#[composable]
452fn SelectionHandles(
453    state: TextFieldState,
454    style: TextStyle,
455    controller: TextFieldHandleController,
456    accent: Color,
457) {
458    let selection = state.selection();
459    let current_range = (selection.min(), selection.max());
460    let active_press = controller.press();
461
462    let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
463    let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
464    let caret_menu_offset: Rc<Cell<usize>> =
465        remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
466    let previous_range: Rc<Cell<(usize, usize)>> =
467        remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
468    {
469        let previous_range = Rc::clone(&previous_range);
470        SideEffect(move || {
471            if previous_range.get() != current_range {
472                previous_range.set(current_range);
473                menu_open.set(true);
474            }
475        });
476    }
477    {
478        let caret_menu_offset = Rc::clone(&caret_menu_offset);
479        let caret_start = selection.start;
480        SideEffect(move || {
481            if caret_menu_open.value()
482                && (!selection.collapsed() || caret_start != caret_menu_offset.get())
483            {
484                caret_menu_open.set(false);
485            }
486        });
487    }
488
489    let Some(metrics) = controller.metrics() else {
490        return;
491    };
492    if !metrics.focused || !metrics.direct_manipulation {
493        return;
494    }
495    // Past the gate the handles are on screen and have to travel with the
496    // field, so from here the scope follows its position too.
497    let Some(metrics) = controller.live_metrics() else {
498        return;
499    };
500
501    let text = state.text();
502
503    let press_watcher: Rc<Cell<Option<(u32, u32)>>> =
504        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
505    let press_watcher_ref: Rc<RefCell<Option<Rc<LongPressWatcher>>>> =
506        remember(|| Rc::new(RefCell::new(None))).with(Rc::clone);
507    match active_press {
508        Some(press) => {
509            let key = (press.start.x.to_bits(), press.start.y.to_bits());
510            if press_watcher.get() != Some(key) {
511                press_watcher.set(Some(key));
512                let watcher = Rc::new(LongPressWatcher {
513                    controller: controller.clone(),
514                    state,
515                    style: style.clone(),
516                    start: press.start,
517                    start_nanos: Cell::new(None),
518                    registration: RefCell::new(None),
519                    frame_clock: cranpose_core::with_current_composer(|composer| {
520                        composer.runtime_handle()
521                    })
522                    .frame_clock(),
523                });
524                watcher.arm();
525                *press_watcher_ref.borrow_mut() = Some(watcher);
526            }
527        }
528        None => {
529            press_watcher.set(None);
530            press_watcher_ref.borrow_mut().take();
531        }
532    }
533
534    let drag_pos: MutableState<Option<Point>> =
535        remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
536    let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> =
537        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
538    let last_dragged: Rc<Cell<Option<HandleKind>>> =
539        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
540    let menu_anchor_range: Rc<Cell<(usize, usize)>> =
541        remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
542    {
543        let last_dragged = Rc::clone(&last_dragged);
544        let menu_anchor_range = Rc::clone(&menu_anchor_range);
545        SideEffect(move || {
546            if menu_anchor_range.get() != current_range {
547                menu_anchor_range.set(current_range);
548                if drag_pos.value().is_none() {
549                    last_dragged.set(None);
550                }
551            }
552        });
553    }
554    let cursor_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
555    let start_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
556    let end_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
557
558    if selection.collapsed() {
559        let (tip, caret_rect) = caret_window_geometry(
560            &text,
561            &style,
562            &metrics,
563            selection.start,
564            LineAffinity::Upstream,
565        );
566        let on_drag = drag_caret_closure(state, style.clone(), controller, Rc::clone(&drag_bias));
567        let open_caret_menu = {
568            let caret_menu_offset = Rc::clone(&caret_menu_offset);
569            move || {
570                caret_menu_offset.set(state.selection().start);
571                caret_menu_open.set(true);
572            }
573        };
574        let on_tap = open_caret_menu.clone();
575        let on_long_press = open_caret_menu;
576        let grab_bias = Rc::clone(&drag_bias);
577        let end_bias = Rc::clone(&drag_bias);
578        cursor_tip_y.set(tip.y);
579        let tip_y = Rc::clone(&cursor_tip_y);
580        SelectionHandle(
581            HandleKind::Cursor,
582            tip,
583            caret_rect.height,
584            HANDLE_RADIUS,
585            accent,
586            move |pos| {
587                track_handle_grab(&grab_bias, HandleKind::Cursor, tip_y.get(), pos.y);
588                drag_pos.set(Some(pos));
589                on_drag(pos);
590            },
591            move || {
592                drag_pos.set(None);
593                end_bias.set(None);
594                crate::cursor_animation::reset_cursor_blink();
595            },
596            on_long_press,
597            on_tap,
598        );
599
600        if caret_menu_open.value() {
601            let can_paste = clipboard_can_paste();
602            let can_undo = state.can_undo();
603            let can_redo = state.can_redo();
604            let undo_state = state;
605            let redo_state = state;
606            CaretActionMenu(
607                MenuAnchor {
608                    center_x: tip.x,
609                    line_top: caret_rect.y,
610                    line_bottom: caret_rect.y + caret_rect.height,
611                },
612                drag_pos.value().is_none(),
613                can_paste,
614                can_undo,
615                can_redo,
616                move || {
617                    clipboard_paste_into_focus();
618                    caret_menu_open.set(false);
619                },
620                move || {
621                    dispatch_select_all();
622                    caret_menu_open.set(false);
623                },
624                move || {
625                    undo_state.undo();
626                    crate::request_render_invalidation();
627                    caret_menu_open.set(false);
628                },
629                move || {
630                    redo_state.redo();
631                    crate::request_render_invalidation();
632                    caret_menu_open.set(false);
633                },
634            );
635        }
636    } else {
637        let start = selection.min();
638        let end = selection.max();
639        let (start_tip, start_rect) =
640            caret_window_geometry(&text, &style, &metrics, start, LineAffinity::Downstream);
641        let (end_tip, end_rect) =
642            caret_window_geometry(&text, &style, &metrics, end, LineAffinity::Upstream);
643
644        let last_dragged_start = Rc::clone(&last_dragged);
645        let last_dragged_end = Rc::clone(&last_dragged);
646        let on_drag_start = drag_edge_closure(
647            HandleKind::SelectionStart,
648            state,
649            style.clone(),
650            controller.clone(),
651            Rc::clone(&drag_bias),
652        );
653        let grab_bias = Rc::clone(&drag_bias);
654        let end_bias = Rc::clone(&drag_bias);
655        start_tip_y.set(start_tip.y);
656        let start_tip_live = Rc::clone(&start_tip_y);
657        SelectionHandle(
658            HandleKind::SelectionStart,
659            start_tip,
660            start_rect.height,
661            HANDLE_RADIUS,
662            accent,
663            move |pos| {
664                track_handle_grab(
665                    &grab_bias,
666                    HandleKind::SelectionStart,
667                    start_tip_live.get(),
668                    pos.y,
669                );
670                last_dragged_start.set(Some(HandleKind::SelectionStart));
671                drag_pos.set(Some(pos));
672                on_drag_start(pos);
673            },
674            move || {
675                drag_pos.set(None);
676                end_bias.set(None);
677            },
678            move || menu_open.set(true),
679            move || menu_open.set(true),
680        );
681
682        let on_drag_end = drag_edge_closure(
683            HandleKind::SelectionEnd,
684            state,
685            style.clone(),
686            controller.clone(),
687            Rc::clone(&drag_bias),
688        );
689        let grab_bias = Rc::clone(&drag_bias);
690        let end_bias = Rc::clone(&drag_bias);
691        end_tip_y.set(end_tip.y);
692        let end_tip_live = Rc::clone(&end_tip_y);
693        SelectionHandle(
694            HandleKind::SelectionEnd,
695            end_tip,
696            end_rect.height,
697            HANDLE_RADIUS,
698            accent,
699            move |pos| {
700                track_handle_grab(
701                    &grab_bias,
702                    HandleKind::SelectionEnd,
703                    end_tip_live.get(),
704                    pos.y,
705                );
706                last_dragged_end.set(Some(HandleKind::SelectionEnd));
707                drag_pos.set(Some(pos));
708                on_drag_end(pos);
709            },
710            move || {
711                drag_pos.set(None);
712                end_bias.set(None);
713            },
714            move || menu_open.set(true),
715            move || menu_open.set(true),
716        );
717
718        if menu_open.value() {
719            let can_paste = clipboard_can_paste();
720            let slide_point = if controller.gesture_claimed() {
721                active_press.map(|press| press.position)
722            } else {
723                None
724            };
725            let (center_x, caret_rect) = match last_dragged.get() {
726                Some(HandleKind::SelectionStart) => (start_tip.x, start_rect),
727                Some(HandleKind::SelectionEnd | HandleKind::Cursor) => (end_tip.x, end_rect),
728                None => ((start_tip.x + end_tip.x) * 0.5, start_rect),
729            };
730            TextSelectionMenu(
731                MenuAnchor {
732                    center_x,
733                    line_top: caret_rect.y,
734                    line_bottom: caret_rect.y + caret_rect.height,
735                },
736                drag_pos.value().is_none(),
737                slide_point,
738                can_paste,
739                move || {
740                    if let Some(text) = dispatch_copy() {
741                        clipboard_write_text(&text);
742                    }
743                    menu_open.set(false);
744                },
745                move || {
746                    if let Some(text) = dispatch_cut() {
747                        clipboard_write_text(&text);
748                    }
749                    menu_open.set(false);
750                },
751                move || {
752                    clipboard_paste_into_focus();
753                    menu_open.set(false);
754                },
755                move || {
756                    dispatch_select_all();
757                    menu_open.set(false);
758                },
759            );
760        }
761    }
762
763    let loupe_target = drag_pos.value().and_then(|finger| {
764        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
765        let offset = window_pos_to_offset(&text, &style, &metrics, finger, bias);
766        let (_, caret_rect) =
767            caret_window_geometry(&text, &style, &metrics, offset, LineAffinity::Upstream);
768        loupe_target_for_drag(finger, caret_rect.y + caret_rect.height, caret_rect.height)
769    });
770    SelectionLoupe(loupe_target);
771}
772
773fn track_handle_grab(
774    drag_bias: &Cell<Option<HandleGrabOffset>>,
775    kind: HandleKind,
776    handle_tip_y: f32,
777    finger_y: f32,
778) -> f32 {
779    let drifts = kind != HandleKind::SelectionStart;
780    let mut grab = drag_bias
781        .get()
782        .unwrap_or_else(|| HandleGrabOffset::begin_for(handle_tip_y, finger_y, drifts));
783    let bias = grab.track(finger_y);
784    drag_bias.set(Some(grab));
785    bias
786}
787
788/// Builds the drag handler for the collapsed cursor handle: moves the caret to
789/// the dragged position. `drag_bias` is the finger-to-line offset captured at
790/// the grab (see [`window_pos_to_offset`]).
791fn drag_caret_closure(
792    state: TextFieldState,
793    style: TextStyle,
794    controller: TextFieldHandleController,
795    drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
796) -> Rc<dyn Fn(Point)> {
797    Rc::new(move |window_pos: Point| {
798        let Some(metrics) = controller.metrics() else {
799            return;
800        };
801        let text = state.text();
802        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
803        let offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
804        state.set_selection(TextRange::new(offset, offset));
805        crate::cursor_animation::suspend_cursor_blink();
806        crate::request_render_invalidation();
807    })
808}
809
810/// Builds the drag handler for a selection start/end handle: extends the
811/// selection to the dragged position while keeping the opposite edge fixed and
812/// never letting the edges cross. `drag_bias` as in [`drag_caret_closure`].
813fn drag_edge_closure(
814    dragged: HandleKind,
815    state: TextFieldState,
816    style: TextStyle,
817    controller: TextFieldHandleController,
818    drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
819) -> Rc<dyn Fn(Point)> {
820    Rc::new(move |window_pos: Point| {
821        let Some(metrics) = controller.metrics() else {
822            return;
823        };
824        let text = state.text();
825        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
826        let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
827        let selection = state.selection();
828        let fixed_edge = match dragged {
829            HandleKind::SelectionStart => selection.max(),
830            _ => selection.min(),
831        };
832        let (min, max) =
833            selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
834        state.set_selection(TextRange::new(min, max));
835        crate::request_render_invalidation();
836    })
837}
838
839#[cfg(test)]
840#[path = "tests/basic_text_field_tests.rs"]
841mod tests;