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