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/// Edits text through a retained [`TextFieldState`]. `modifier` controls the
205/// field bounds and input, and `style` controls the text. Use
206/// [`BasicTextFieldWithOptions`] for line limits, cursor color and edit callbacks.
207///
208/// # Example
209///
210/// ```rust
211/// use cranpose_ui::*;
212///
213/// #[composable]
214/// fn NameField() {
215///     let text =
216///         cranpose_core::remember(|| TextFieldState::new("Initial text")).with(|state| *state);
217///     BasicTextField(text, Modifier::empty().padding(8.0), TextStyle::default());
218/// }
219/// ```
220#[composable]
221pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
222    BasicTextFieldWithOptions(
223        state,
224        modifier,
225        BasicTextFieldOptions {
226            text_style: style,
227            ..BasicTextFieldOptions::default()
228        },
229    )
230}
231
232/// Options for customizing BasicTextField appearance and behavior.
233#[derive(Debug, Clone, PartialEq)]
234pub struct BasicTextFieldOptions {
235    /// Text style
236    pub text_style: TextStyle,
237    /// Cursor color
238    pub cursor_color: Color,
239    /// Line limits: SingleLine or MultiLine with optional min/max
240    pub line_limits: TextFieldLineLimits,
241    /// Whether gaining focus opens the software keyboard. Defaults to `true`.
242    ///
243    /// When `false`, programmatic and keyboard-navigation focus leave the
244    /// software keyboard hidden. A pointer tap still explicitly opens it.
245    /// Changing this to `true` while focused also requests the keyboard.
246    /// Changing it to `false` preserves an already visible keyboard.
247    pub show_keyboard_on_focus: bool,
248}
249
250impl Default for BasicTextFieldOptions {
251    fn default() -> Self {
252        Self {
253            text_style: TextStyle::default(),
254            cursor_color: Color(0.0, 0.478, 1.0, 1.0),
255            line_limits: TextFieldLineLimits::default(),
256            show_keyboard_on_focus: true,
257        }
258    }
259}
260
261/// Scope passed to a basic text field decoration box.
262///
263/// The decoration may place arbitrary composables around the editable content,
264/// but must call [`inner_text_field`](Self::inner_text_field) exactly once.
265#[derive(Clone)]
266pub struct BasicTextFieldDecorationScope {
267    inner: Rc<dyn Fn() -> NodeId>,
268}
269
270impl BasicTextFieldDecorationScope {
271    pub fn inner_text_field(&self) -> NodeId {
272        (self.inner)()
273    }
274}
275
276impl PartialEq for BasicTextFieldDecorationScope {
277    fn eq(&self, other: &Self) -> bool {
278        Rc::ptr_eq(&self.inner, &other.inner)
279    }
280}
281
282/// Creates an editable field and lets `decoration_box` place composable labels,
283/// placeholders, icons, buttons, prefixes, or suffixes around it.
284///
285/// As in Compose, the decoration box is the field: `modifier` applies to it,
286/// and it takes the field's pointer input, focus and semantics, so a press
287/// anywhere in it edits the text. The inner field only lays out and draws
288/// the text, as wide as the text unless the decoration stretches it (a
289/// `Box` that propagates its minimum constraints, for one).
290#[composable(no_skip)]
291pub fn BasicTextFieldDecorated<D>(
292    state: TextFieldState,
293    modifier: Modifier,
294    options: BasicTextFieldOptions,
295    decoration_box: D,
296) -> NodeId
297where
298    D: Fn(BasicTextFieldDecorationScope) + 'static,
299{
300    let refs = remember(TextFieldRefs::new).with(TextFieldRefs::clone);
301    let decorator = TextFieldDecoratorElement::new(
302        state,
303        refs.clone(),
304        options.text_style.clone(),
305        options.line_limits,
306        crate::modal::local_modal_depth().current(),
307    );
308    let scope = BasicTextFieldDecorationScope {
309        inner: Rc::new(move || {
310            TextFieldNode(
311                state,
312                Modifier::empty(),
313                options.clone(),
314                Some(refs.clone()),
315            )
316        }),
317    };
318    Layout(
319        modifier.then(Modifier::from_parts(&[modifier_element(decorator)])),
320        BoxMeasurePolicy::new(Alignment::TOP_START, true),
321        move || decoration_box(scope.clone()),
322    )
323}
324
325/// Creates an editable text field with custom options.
326///
327/// This is the full version of `BasicTextField` with all configuration options.
328#[composable]
329pub fn BasicTextFieldWithOptions(
330    state: TextFieldState,
331    modifier: Modifier,
332    options: BasicTextFieldOptions,
333) -> NodeId {
334    TextFieldNode(state, modifier, options, None)
335}
336
337/// The field's node, and the handles and caret tracking around it. With
338/// `decorator`, the decoration box sharing those refs takes the field's
339/// input, focus and semantics.
340#[composable]
341fn TextFieldNode(
342    state: TextFieldState,
343    modifier: Modifier,
344    options: BasicTextFieldOptions,
345    decorator: Option<TextFieldRefs>,
346) -> NodeId {
347    let _text = state.text();
348    let _selection = state.selection();
349
350    let controller =
351        remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
352
353    let modal_depth = crate::modal::local_modal_depth().current();
354    let mut text_field_element = TextFieldElement::new(state, options.text_style.clone())
355        .with_cursor_color(options.cursor_color)
356        .with_line_limits(options.line_limits)
357        .with_show_keyboard_on_focus(options.show_keyboard_on_focus)
358        .with_handle_controller(controller.clone())
359        .with_modal_depth(modal_depth);
360    if let Some(refs) = decorator {
361        text_field_element = text_field_element.decorated_by(refs);
362    }
363
364    let text_field_modifier = modifier_element(text_field_element);
365    let final_modifier = Modifier::from_parts(&[text_field_modifier]);
366    let combined_modifier = modifier.then(final_modifier);
367
368    let node = Layout(combined_modifier, EmptyMeasurePolicy, || {});
369
370    BringCaretIntoView(state, options.text_style.clone(), controller.clone());
371
372    SelectionHandles(state, options.text_style, controller, options.cursor_color);
373
374    node
375}
376
377#[cfg(test)]
378#[path = "tests/basic_text_field_options_tests.rs"]
379mod options_tests;
380
381/// Window-space rect of the field's caret (the cursor line at byte `offset`),
382/// derived from the field's published handle [`TextFieldHandleMetrics`]. Its top
383/// is the top of the caret's visual line; its height is one line.
384fn caret_window_rect(
385    text: &str,
386    style: &TextStyle,
387    metrics: &TextFieldHandleMetrics,
388    offset: usize,
389) -> Rect {
390    caret_window_geometry(text, style, metrics, offset, LineAffinity::Upstream).1
391}
392
393/// Consumer half of bug 2: while the field is focused, asks the nearest scroll
394/// container (via [`local_bring_into_view_responder`]) to scroll the caret clear
395/// of the on-screen keyboard ([`local_ime_insets`]).
396///
397/// The request is triggered only by focus, caret movement, or a change in the
398/// keyboard inset — never by scrolling — so the user is never yanked back while
399/// deliberately scrolling the field out of view. The caret rect handed to the
400/// responder is always recomputed from the live metrics, so the scroll delta is
401/// correct even as the keyboard animates in.
402#[composable]
403fn BringCaretIntoView(
404    state: TextFieldState,
405    style: TextStyle,
406    controller: TextFieldHandleController,
407) {
408    let Some(metrics) = controller.metrics() else {
409        return;
410    };
411    let ime_bottom = local_ime_insets().current().bottom;
412    let responder = local_bring_into_view_responder().current();
413
414    let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
415        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
416
417    if !metrics.focused {
418        previous.set(None);
419        return;
420    }
421    let Some(responder) = responder else {
422        return;
423    };
424
425    let text = state.text();
426    let selection = state.selection();
427
428    let key = (
429        selection.start,
430        selection.end,
431        (ime_bottom * 4.0).round() as i64,
432    );
433    SideEffect(move || {
434        if previous.get() == Some(key) {
435            return;
436        }
437        previous.set(Some(key));
438        let Some(metrics) = controller.metrics_now() else {
439            return;
440        };
441        let caret = caret_window_rect(&text, &style, &metrics, selection.start);
442        responder.bring_into_view(caret, ime_bottom);
443    });
444}
445
446/// Emits selection/cursor handles for a focused field entered through any
447/// primary pointer. Keyboard-only focus keeps a clean caret.
448/// `accent` is the field's tint (its cursor color): handles are drawn solid in
449/// it, matching the caret and the highlight derived from it.
450#[composable]
451fn SelectionHandles(
452    state: TextFieldState,
453    style: TextStyle,
454    controller: TextFieldHandleController,
455    accent: Color,
456) {
457    let selection = state.selection();
458    let current_range = (selection.min(), selection.max());
459    let active_press = controller.press();
460
461    let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
462    let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
463    let caret_menu_offset: Rc<Cell<usize>> =
464        remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
465    let previous_range: Rc<Cell<(usize, usize)>> =
466        remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
467    {
468        let previous_range = Rc::clone(&previous_range);
469        SideEffect(move || {
470            if previous_range.get() != current_range {
471                previous_range.set(current_range);
472                menu_open.set(true);
473            }
474        });
475    }
476    {
477        let caret_menu_offset = Rc::clone(&caret_menu_offset);
478        let caret_start = selection.start;
479        SideEffect(move || {
480            if caret_menu_open.value()
481                && (!selection.collapsed() || caret_start != caret_menu_offset.get())
482            {
483                caret_menu_open.set(false);
484            }
485        });
486    }
487
488    let Some(metrics) = controller.metrics() else {
489        return;
490    };
491    if !metrics.focused || !metrics.direct_manipulation {
492        return;
493    }
494    // Past the gate the handles are on screen and have to travel with the
495    // field, so from here the scope follows its position too.
496    let Some(metrics) = controller.live_metrics() else {
497        return;
498    };
499
500    let text = state.text();
501
502    let press_watcher: Rc<Cell<Option<(u32, u32)>>> =
503        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
504    let press_watcher_ref: Rc<RefCell<Option<Rc<LongPressWatcher>>>> =
505        remember(|| Rc::new(RefCell::new(None))).with(Rc::clone);
506    match active_press {
507        Some(press) => {
508            let key = (press.start.x.to_bits(), press.start.y.to_bits());
509            if press_watcher.get() != Some(key) {
510                press_watcher.set(Some(key));
511                let watcher = Rc::new(LongPressWatcher {
512                    controller: controller.clone(),
513                    state,
514                    style: style.clone(),
515                    start: press.start,
516                    start_nanos: Cell::new(None),
517                    registration: RefCell::new(None),
518                    frame_clock: cranpose_core::with_current_composer(|composer| {
519                        composer.runtime_handle()
520                    })
521                    .frame_clock(),
522                });
523                watcher.arm();
524                *press_watcher_ref.borrow_mut() = Some(watcher);
525            }
526        }
527        None => {
528            press_watcher.set(None);
529            press_watcher_ref.borrow_mut().take();
530        }
531    }
532
533    let drag_pos: MutableState<Option<Point>> =
534        remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
535    let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> =
536        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
537    let last_dragged: Rc<Cell<Option<HandleKind>>> =
538        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
539    let menu_anchor_range: Rc<Cell<(usize, usize)>> =
540        remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
541    {
542        let last_dragged = Rc::clone(&last_dragged);
543        let menu_anchor_range = Rc::clone(&menu_anchor_range);
544        SideEffect(move || {
545            if menu_anchor_range.get() != current_range {
546                menu_anchor_range.set(current_range);
547                if drag_pos.value().is_none() {
548                    last_dragged.set(None);
549                }
550            }
551        });
552    }
553    let cursor_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
554    let start_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
555    let end_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
556
557    if selection.collapsed() {
558        let (tip, caret_rect) = caret_window_geometry(
559            &text,
560            &style,
561            &metrics,
562            selection.start,
563            LineAffinity::Upstream,
564        );
565        let on_drag = drag_caret_closure(state, style.clone(), controller, Rc::clone(&drag_bias));
566        let open_caret_menu = {
567            let caret_menu_offset = Rc::clone(&caret_menu_offset);
568            move || {
569                caret_menu_offset.set(state.selection().start);
570                caret_menu_open.set(true);
571            }
572        };
573        let on_tap = open_caret_menu.clone();
574        let on_long_press = open_caret_menu;
575        let grab_bias = Rc::clone(&drag_bias);
576        let end_bias = Rc::clone(&drag_bias);
577        cursor_tip_y.set(tip.y);
578        let tip_y = Rc::clone(&cursor_tip_y);
579        SelectionHandle(
580            HandleKind::Cursor,
581            tip,
582            caret_rect.height,
583            HANDLE_RADIUS,
584            accent,
585            move |pos| {
586                track_handle_grab(&grab_bias, HandleKind::Cursor, tip_y.get(), pos.y);
587                drag_pos.set(Some(pos));
588                on_drag(pos);
589            },
590            move || {
591                drag_pos.set(None);
592                end_bias.set(None);
593                crate::cursor_animation::reset_cursor_blink();
594            },
595            on_long_press,
596            on_tap,
597        );
598
599        if caret_menu_open.value() {
600            let can_paste = clipboard_can_paste();
601            let can_undo = state.can_undo();
602            let can_redo = state.can_redo();
603            let undo_state = state;
604            let redo_state = state;
605            CaretActionMenu(
606                MenuAnchor {
607                    center_x: tip.x,
608                    line_top: caret_rect.y,
609                    line_bottom: caret_rect.y + caret_rect.height,
610                },
611                drag_pos.value().is_none(),
612                can_paste,
613                can_undo,
614                can_redo,
615                move || {
616                    clipboard_paste_into_focus();
617                    caret_menu_open.set(false);
618                },
619                move || {
620                    dispatch_select_all();
621                    caret_menu_open.set(false);
622                },
623                move || {
624                    undo_state.undo();
625                    crate::request_render_invalidation();
626                    caret_menu_open.set(false);
627                },
628                move || {
629                    redo_state.redo();
630                    crate::request_render_invalidation();
631                    caret_menu_open.set(false);
632                },
633            );
634        }
635    } else {
636        let start = selection.min();
637        let end = selection.max();
638        let (start_tip, start_rect) =
639            caret_window_geometry(&text, &style, &metrics, start, LineAffinity::Downstream);
640        let (end_tip, end_rect) =
641            caret_window_geometry(&text, &style, &metrics, end, LineAffinity::Upstream);
642
643        let last_dragged_start = Rc::clone(&last_dragged);
644        let last_dragged_end = Rc::clone(&last_dragged);
645        let on_drag_start = drag_edge_closure(
646            HandleKind::SelectionStart,
647            state,
648            style.clone(),
649            controller.clone(),
650            Rc::clone(&drag_bias),
651        );
652        let grab_bias = Rc::clone(&drag_bias);
653        let end_bias = Rc::clone(&drag_bias);
654        start_tip_y.set(start_tip.y);
655        let start_tip_live = Rc::clone(&start_tip_y);
656        SelectionHandle(
657            HandleKind::SelectionStart,
658            start_tip,
659            start_rect.height,
660            HANDLE_RADIUS,
661            accent,
662            move |pos| {
663                track_handle_grab(
664                    &grab_bias,
665                    HandleKind::SelectionStart,
666                    start_tip_live.get(),
667                    pos.y,
668                );
669                last_dragged_start.set(Some(HandleKind::SelectionStart));
670                drag_pos.set(Some(pos));
671                on_drag_start(pos);
672            },
673            move || {
674                drag_pos.set(None);
675                end_bias.set(None);
676            },
677            move || menu_open.set(true),
678            move || menu_open.set(true),
679        );
680
681        let on_drag_end = drag_edge_closure(
682            HandleKind::SelectionEnd,
683            state,
684            style.clone(),
685            controller.clone(),
686            Rc::clone(&drag_bias),
687        );
688        let grab_bias = Rc::clone(&drag_bias);
689        let end_bias = Rc::clone(&drag_bias);
690        end_tip_y.set(end_tip.y);
691        let end_tip_live = Rc::clone(&end_tip_y);
692        SelectionHandle(
693            HandleKind::SelectionEnd,
694            end_tip,
695            end_rect.height,
696            HANDLE_RADIUS,
697            accent,
698            move |pos| {
699                track_handle_grab(
700                    &grab_bias,
701                    HandleKind::SelectionEnd,
702                    end_tip_live.get(),
703                    pos.y,
704                );
705                last_dragged_end.set(Some(HandleKind::SelectionEnd));
706                drag_pos.set(Some(pos));
707                on_drag_end(pos);
708            },
709            move || {
710                drag_pos.set(None);
711                end_bias.set(None);
712            },
713            move || menu_open.set(true),
714            move || menu_open.set(true),
715        );
716
717        if menu_open.value() {
718            let can_paste = clipboard_can_paste();
719            let slide_point = if controller.gesture_claimed() {
720                active_press.map(|press| press.position)
721            } else {
722                None
723            };
724            let (center_x, caret_rect) = match last_dragged.get() {
725                Some(HandleKind::SelectionStart) => (start_tip.x, start_rect),
726                Some(HandleKind::SelectionEnd | HandleKind::Cursor) => (end_tip.x, end_rect),
727                None => ((start_tip.x + end_tip.x) * 0.5, start_rect),
728            };
729            TextSelectionMenu(
730                MenuAnchor {
731                    center_x,
732                    line_top: caret_rect.y,
733                    line_bottom: caret_rect.y + caret_rect.height,
734                },
735                drag_pos.value().is_none(),
736                slide_point,
737                can_paste,
738                move || {
739                    if let Some(text) = dispatch_copy() {
740                        clipboard_write_text(&text);
741                    }
742                    menu_open.set(false);
743                },
744                move || {
745                    if let Some(text) = dispatch_cut() {
746                        clipboard_write_text(&text);
747                    }
748                    menu_open.set(false);
749                },
750                move || {
751                    clipboard_paste_into_focus();
752                    menu_open.set(false);
753                },
754                move || {
755                    dispatch_select_all();
756                    menu_open.set(false);
757                },
758            );
759        }
760    }
761
762    let loupe_target = drag_pos.value().and_then(|finger| {
763        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
764        let offset = window_pos_to_offset(&text, &style, &metrics, finger, bias);
765        let (_, caret_rect) =
766            caret_window_geometry(&text, &style, &metrics, offset, LineAffinity::Upstream);
767        loupe_target_for_drag(finger, caret_rect.y + caret_rect.height, caret_rect.height)
768    });
769    SelectionLoupe(loupe_target);
770}
771
772fn track_handle_grab(
773    drag_bias: &Cell<Option<HandleGrabOffset>>,
774    kind: HandleKind,
775    handle_tip_y: f32,
776    finger_y: f32,
777) -> f32 {
778    let drifts = kind != HandleKind::SelectionStart;
779    let mut grab = drag_bias
780        .get()
781        .unwrap_or_else(|| HandleGrabOffset::begin_for(handle_tip_y, finger_y, drifts));
782    let bias = grab.track(finger_y);
783    drag_bias.set(Some(grab));
784    bias
785}
786
787/// Builds the drag handler for the collapsed cursor handle: moves the caret to
788/// the dragged position. `drag_bias` is the finger-to-line offset captured at
789/// the grab (see [`window_pos_to_offset`]).
790fn drag_caret_closure(
791    state: TextFieldState,
792    style: TextStyle,
793    controller: TextFieldHandleController,
794    drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
795) -> Rc<dyn Fn(Point)> {
796    Rc::new(move |window_pos: Point| {
797        let Some(metrics) = controller.metrics() else {
798            return;
799        };
800        let text = state.text();
801        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
802        let offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
803        state.set_selection(TextRange::new(offset, offset));
804        crate::cursor_animation::suspend_cursor_blink();
805        crate::request_render_invalidation();
806    })
807}
808
809/// Builds the drag handler for a selection start/end handle: extends the
810/// selection to the dragged position while keeping the opposite edge fixed and
811/// never letting the edges cross. `drag_bias` as in [`drag_caret_closure`].
812fn drag_edge_closure(
813    dragged: HandleKind,
814    state: TextFieldState,
815    style: TextStyle,
816    controller: TextFieldHandleController,
817    drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
818) -> Rc<dyn Fn(Point)> {
819    Rc::new(move |window_pos: Point| {
820        let Some(metrics) = controller.metrics() else {
821            return;
822        };
823        let text = state.text();
824        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
825        let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
826        let selection = state.selection();
827        let fixed_edge = match dragged {
828            HandleKind::SelectionStart => selection.max(),
829            _ => selection.min(),
830        };
831        let (min, max) =
832            selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
833        state.set_selection(TextRange::new(min, max));
834        crate::request_render_invalidation();
835    })
836}
837
838#[cfg(test)]
839#[path = "tests/basic_text_field_tests.rs"]
840mod tests;