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