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    /// Composes the editable text area and returns its node id. The decoration box must call this
300    /// exactly once.
301    pub fn inner_text_field(&self) -> NodeId {
302        (self.inner)()
303    }
304}
305
306impl PartialEq for BasicTextFieldDecorationScope {
307    fn eq(&self, other: &Self) -> bool {
308        Rc::ptr_eq(&self.inner, &other.inner)
309    }
310}
311
312/// Creates an editable field and lets `decoration_box` place composable labels,
313/// placeholders, icons, buttons, prefixes, or suffixes around it.
314///
315/// As in Compose, the decoration box is the field: `modifier` applies to it,
316/// and it takes the field's pointer input, focus and semantics, so a press
317/// anywhere in it edits the text. The inner field only lays out and draws
318/// the text, as wide as the text unless the decoration stretches it (a
319/// `Box` that propagates its minimum constraints, for one).
320#[composable(no_skip)]
321pub fn BasicTextFieldDecorated<D>(
322    state: TextFieldState,
323    modifier: Modifier,
324    options: BasicTextFieldOptions,
325    decoration_box: D,
326) -> NodeId
327where
328    D: Fn(BasicTextFieldDecorationScope) + 'static,
329{
330    let refs = remember(TextFieldRefs::new).with(TextFieldRefs::clone);
331    let decorator = TextFieldDecoratorElement::new(
332        state,
333        refs.clone(),
334        options.text_style.clone(),
335        options.line_limits,
336        crate::modal::local_modal_depth().current(),
337    );
338    let scope = BasicTextFieldDecorationScope {
339        inner: Rc::new(move || {
340            TextFieldNode(
341                state,
342                Modifier::empty(),
343                options.clone(),
344                Some(refs.clone()),
345                false,
346            )
347        }),
348    };
349    Layout(
350        modifier.then(Modifier::from_parts(&[modifier_element(decorator)])),
351        BoxMeasurePolicy::new(Alignment::TOP_START, true),
352        move || decoration_box(scope.clone()),
353    )
354}
355
356/// Creates an editable text field with custom options.
357///
358/// This is the full version of `BasicTextField` with all configuration options.
359#[composable]
360pub fn BasicTextFieldWithOptions(
361    state: TextFieldState,
362    modifier: Modifier,
363    options: BasicTextFieldOptions,
364) -> NodeId {
365    TextFieldNode(state, modifier, options, None, false)
366}
367
368/// The field's node, and the handles and caret tracking around it. With
369/// `decorator`, the decoration box sharing those refs takes the field's
370/// input, focus and semantics. A `secure` field holds a secret.
371#[composable]
372pub(crate) fn TextFieldNode(
373    state: TextFieldState,
374    modifier: Modifier,
375    options: BasicTextFieldOptions,
376    decorator: Option<TextFieldRefs>,
377    secure: bool,
378) -> NodeId {
379    #[cfg(feature = "localization")]
380    let options = BasicTextFieldOptions {
381        text_style: crate::localization::apply_text_locale(options.text_style),
382        ..options
383    };
384    state.with_text(|_| ());
385    let _selection = state.selection();
386
387    let controller =
388        remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
389
390    let modal_depth = crate::modal::local_modal_depth().current();
391    let mut text_field_element = TextFieldElement::new(state, options.text_style.clone())
392        .with_cursor_color(options.cursor_color)
393        .with_line_limits(options.line_limits)
394        .with_show_keyboard_on_focus(options.show_keyboard_on_focus)
395        .with_keyboard(options.keyboard_options, options.on_keyboard_action)
396        .with_input_transformation(options.input_transformation)
397        .with_output_transformation(options.output_transformation)
398        .secure(secure)
399        .with_handle_controller(controller.clone())
400        .with_modal_depth(modal_depth);
401    if let Some(refs) = decorator {
402        text_field_element = text_field_element.decorated_by(refs);
403    }
404
405    let text_field_modifier = modifier_element(text_field_element);
406    let final_modifier = Modifier::from_parts(&[text_field_modifier]);
407    let combined_modifier = modifier.then(final_modifier);
408
409    let node = Layout(combined_modifier, EmptyMeasurePolicy, || {});
410
411    BringCaretIntoView(state, options.text_style.clone(), controller.clone());
412
413    SelectionHandles(
414        state,
415        options.text_style,
416        controller,
417        options.cursor_color,
418        secure,
419    );
420
421    node
422}
423
424#[cfg(test)]
425#[path = "tests/basic_text_field_options_tests.rs"]
426mod options_tests;
427
428/// Window-space rect of the field's caret (the cursor line at byte `offset`),
429/// derived from the field's published handle [`TextFieldHandleMetrics`]. Its top
430/// is the top of the caret's visual line; its height is one line.
431fn caret_window_rect(
432    text: &str,
433    style: &TextStyle,
434    metrics: &TextFieldHandleMetrics,
435    offset: usize,
436) -> Rect {
437    caret_window_geometry(text, style, metrics, offset, LineAffinity::Upstream).1
438}
439
440/// Consumer half of bug 2: while the field is focused, asks the nearest scroll
441/// container (via [`local_bring_into_view_responder`]) to scroll the caret clear
442/// of the on-screen keyboard ([`local_ime_insets`]).
443///
444/// The request is triggered only by focus, caret movement, or a change in the
445/// keyboard inset — never by scrolling — so the user is never yanked back while
446/// deliberately scrolling the field out of view. The caret rect handed to the
447/// responder is always recomputed from the live metrics, so the scroll delta is
448/// correct even as the keyboard animates in.
449#[composable]
450fn BringCaretIntoView(
451    state: TextFieldState,
452    style: TextStyle,
453    controller: TextFieldHandleController,
454) {
455    let Some(metrics) = controller.metrics() else {
456        return;
457    };
458    let ime_bottom = local_ime_insets().current().bottom;
459    let responder = local_bring_into_view_responder().current();
460
461    let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
462        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
463
464    if !metrics.focused {
465        previous.set(None);
466        return;
467    }
468    let Some(responder) = responder else {
469        return;
470    };
471
472    let presentation = controller.presentation(state);
473    let selection = state.selection();
474
475    let key = (
476        selection.start,
477        selection.end,
478        (ime_bottom * 4.0).round() as i64,
479    );
480    SideEffect(move || {
481        if previous.get() == Some(key) {
482            return;
483        }
484        previous.set(Some(key));
485        let Some(metrics) = controller.metrics_now() else {
486            return;
487        };
488        let caret = caret_window_rect(
489            presentation.text(),
490            &style,
491            &metrics,
492            presentation.to_shown(selection.start),
493        );
494        responder.bring_into_view(caret, ime_bottom);
495    });
496}
497
498/// Emits selection/cursor handles for a focused field entered through any
499/// primary pointer. Keyboard-only focus keeps a clean caret.
500/// `accent` is the field's tint (its cursor color): handles are drawn solid in
501/// it, matching the caret and the highlight derived from it.
502#[composable]
503fn SelectionHandles(
504    state: TextFieldState,
505    style: TextStyle,
506    controller: TextFieldHandleController,
507    accent: Color,
508    secure: bool,
509) {
510    let selection = state.selection();
511    let current_range = (selection.min(), selection.max());
512    let active_press = controller.press();
513
514    let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
515    let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
516    let caret_menu_offset: Rc<Cell<usize>> =
517        remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
518    let previous_range: Rc<Cell<(usize, usize)>> =
519        remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
520    {
521        let previous_range = Rc::clone(&previous_range);
522        SideEffect(move || {
523            if previous_range.get() != current_range {
524                previous_range.set(current_range);
525                menu_open.set(true);
526            }
527        });
528    }
529    {
530        let caret_menu_offset = Rc::clone(&caret_menu_offset);
531        let caret_start = selection.start;
532        SideEffect(move || {
533            if caret_menu_open.value()
534                && (!selection.collapsed() || caret_start != caret_menu_offset.get())
535            {
536                caret_menu_open.set(false);
537            }
538        });
539    }
540
541    let Some(metrics) = controller.metrics() else {
542        return;
543    };
544    if !metrics.focused || !metrics.direct_manipulation {
545        return;
546    }
547    // Past the gate the handles are on screen and have to travel with the
548    // field, so from here the scope follows its position too.
549    let Some(metrics) = controller.live_metrics() else {
550        return;
551    };
552
553    let presentation = controller.presentation(state);
554    let text = presentation.text();
555
556    let press_watcher: Rc<Cell<Option<(u32, u32)>>> =
557        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
558    let press_watcher_ref: Rc<RefCell<Option<Rc<LongPressWatcher>>>> =
559        remember(|| Rc::new(RefCell::new(None))).with(Rc::clone);
560    match active_press {
561        Some(press) => {
562            let key = (press.start.x.to_bits(), press.start.y.to_bits());
563            if press_watcher.get() != Some(key) {
564                press_watcher.set(Some(key));
565                let watcher = Rc::new(LongPressWatcher {
566                    controller: controller.clone(),
567                    state,
568                    style: style.clone(),
569                    start: press.start,
570                    start_nanos: Cell::new(None),
571                    registration: RefCell::new(None),
572                    frame_clock: cranpose_core::with_current_composer(|composer| {
573                        composer.runtime_handle()
574                    })
575                    .frame_clock(),
576                });
577                watcher.arm();
578                *press_watcher_ref.borrow_mut() = Some(watcher);
579            }
580        }
581        None => {
582            press_watcher.set(None);
583            press_watcher_ref.borrow_mut().take();
584        }
585    }
586
587    let drag_pos: MutableState<Option<Point>> =
588        remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
589    let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> =
590        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
591    let last_dragged: Rc<Cell<Option<HandleKind>>> =
592        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
593    let menu_anchor_range: Rc<Cell<(usize, usize)>> =
594        remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
595    {
596        let last_dragged = Rc::clone(&last_dragged);
597        let menu_anchor_range = Rc::clone(&menu_anchor_range);
598        SideEffect(move || {
599            if menu_anchor_range.get() != current_range {
600                menu_anchor_range.set(current_range);
601                if drag_pos.value().is_none() {
602                    last_dragged.set(None);
603                }
604            }
605        });
606    }
607    let cursor_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
608    let start_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
609    let end_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
610
611    if selection.collapsed() {
612        let (tip, caret_rect) = caret_window_geometry(
613            text,
614            &style,
615            &metrics,
616            presentation.to_shown(selection.start),
617            LineAffinity::Upstream,
618        );
619        let on_drag = drag_caret_closure(state, style.clone(), controller, Rc::clone(&drag_bias));
620        let open_caret_menu = {
621            let caret_menu_offset = Rc::clone(&caret_menu_offset);
622            move || {
623                caret_menu_offset.set(state.selection().start);
624                caret_menu_open.set(true);
625            }
626        };
627        let on_tap = open_caret_menu.clone();
628        let on_long_press = open_caret_menu;
629        let grab_bias = Rc::clone(&drag_bias);
630        let end_bias = Rc::clone(&drag_bias);
631        cursor_tip_y.set(tip.y);
632        let tip_y = Rc::clone(&cursor_tip_y);
633        SelectionHandle(
634            HandleKind::Cursor,
635            tip,
636            caret_rect.height,
637            HANDLE_RADIUS,
638            accent,
639            move |pos| {
640                track_handle_grab(&grab_bias, HandleKind::Cursor, tip_y.get(), pos.y);
641                drag_pos.set(Some(pos));
642                on_drag(pos);
643            },
644            move || {
645                drag_pos.set(None);
646                end_bias.set(None);
647                crate::cursor_animation::reset_cursor_blink();
648            },
649            on_long_press,
650            on_tap,
651        );
652
653        if caret_menu_open.value() {
654            let can_paste = clipboard_can_paste();
655            let can_undo = state.can_undo();
656            let can_redo = state.can_redo();
657            let undo_state = state;
658            let redo_state = state;
659            CaretActionMenu(
660                MenuAnchor {
661                    center_x: tip.x,
662                    line_top: caret_rect.y,
663                    line_bottom: caret_rect.y + caret_rect.height,
664                },
665                drag_pos.value().is_none(),
666                can_paste,
667                can_undo,
668                can_redo,
669                move || {
670                    clipboard_paste_into_focus();
671                    caret_menu_open.set(false);
672                },
673                move || {
674                    dispatch_select_all();
675                    caret_menu_open.set(false);
676                },
677                move || {
678                    undo_state.undo();
679                    crate::request_render_invalidation();
680                    caret_menu_open.set(false);
681                },
682                move || {
683                    redo_state.redo();
684                    crate::request_render_invalidation();
685                    caret_menu_open.set(false);
686                },
687            );
688        }
689    } else {
690        let (start, end) = presentation.range_to_shown(selection);
691        let (start_tip, start_rect) =
692            caret_window_geometry(text, &style, &metrics, start, LineAffinity::Downstream);
693        let (end_tip, end_rect) =
694            caret_window_geometry(text, &style, &metrics, end, LineAffinity::Upstream);
695
696        let last_dragged_start = Rc::clone(&last_dragged);
697        let last_dragged_end = Rc::clone(&last_dragged);
698        let on_drag_start = drag_edge_closure(
699            HandleKind::SelectionStart,
700            state,
701            style.clone(),
702            controller.clone(),
703            Rc::clone(&drag_bias),
704        );
705        let grab_bias = Rc::clone(&drag_bias);
706        let end_bias = Rc::clone(&drag_bias);
707        start_tip_y.set(start_tip.y);
708        let start_tip_live = Rc::clone(&start_tip_y);
709        SelectionHandle(
710            HandleKind::SelectionStart,
711            start_tip,
712            start_rect.height,
713            HANDLE_RADIUS,
714            accent,
715            move |pos| {
716                track_handle_grab(
717                    &grab_bias,
718                    HandleKind::SelectionStart,
719                    start_tip_live.get(),
720                    pos.y,
721                );
722                last_dragged_start.set(Some(HandleKind::SelectionStart));
723                drag_pos.set(Some(pos));
724                on_drag_start(pos);
725            },
726            move || {
727                drag_pos.set(None);
728                end_bias.set(None);
729            },
730            move || menu_open.set(true),
731            move || menu_open.set(true),
732        );
733
734        let on_drag_end = drag_edge_closure(
735            HandleKind::SelectionEnd,
736            state,
737            style.clone(),
738            controller.clone(),
739            Rc::clone(&drag_bias),
740        );
741        let grab_bias = Rc::clone(&drag_bias);
742        let end_bias = Rc::clone(&drag_bias);
743        end_tip_y.set(end_tip.y);
744        let end_tip_live = Rc::clone(&end_tip_y);
745        SelectionHandle(
746            HandleKind::SelectionEnd,
747            end_tip,
748            end_rect.height,
749            HANDLE_RADIUS,
750            accent,
751            move |pos| {
752                track_handle_grab(
753                    &grab_bias,
754                    HandleKind::SelectionEnd,
755                    end_tip_live.get(),
756                    pos.y,
757                );
758                last_dragged_end.set(Some(HandleKind::SelectionEnd));
759                drag_pos.set(Some(pos));
760                on_drag_end(pos);
761            },
762            move || {
763                drag_pos.set(None);
764                end_bias.set(None);
765            },
766            move || menu_open.set(true),
767            move || menu_open.set(true),
768        );
769
770        if menu_open.value() {
771            let can_paste = clipboard_can_paste();
772            let slide_point = if controller.gesture_claimed() {
773                active_press.map(|press| press.position)
774            } else {
775                None
776            };
777            let (center_x, caret_rect) = match last_dragged.get() {
778                Some(HandleKind::SelectionStart) => (start_tip.x, start_rect),
779                Some(HandleKind::SelectionEnd | HandleKind::Cursor) => (end_tip.x, end_rect),
780                None => ((start_tip.x + end_tip.x) * 0.5, start_rect),
781            };
782            TextSelectionMenu(
783                MenuAnchor {
784                    center_x,
785                    line_top: caret_rect.y,
786                    line_bottom: caret_rect.y + caret_rect.height,
787                },
788                drag_pos.value().is_none(),
789                slide_point,
790                !secure,
791                can_paste,
792                move || {
793                    if let Some(text) = dispatch_copy() {
794                        clipboard_write_text(&text);
795                    }
796                    menu_open.set(false);
797                },
798                move || {
799                    if let Some(text) = dispatch_cut() {
800                        clipboard_write_text(&text);
801                    }
802                    menu_open.set(false);
803                },
804                move || {
805                    clipboard_paste_into_focus();
806                    menu_open.set(false);
807                },
808                move || {
809                    dispatch_select_all();
810                    menu_open.set(false);
811                },
812            );
813        }
814    }
815
816    let loupe_target = drag_pos.value().and_then(|finger| {
817        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
818        let offset = window_pos_to_offset(text, &style, &metrics, finger, bias);
819        let (_, caret_rect) =
820            caret_window_geometry(text, &style, &metrics, offset, LineAffinity::Upstream);
821        loupe_target_for_drag(finger, caret_rect.y + caret_rect.height, caret_rect.height)
822    });
823    SelectionLoupe(loupe_target);
824}
825
826fn track_handle_grab(
827    drag_bias: &Cell<Option<HandleGrabOffset>>,
828    kind: HandleKind,
829    handle_tip_y: f32,
830    finger_y: f32,
831) -> f32 {
832    let drifts = kind != HandleKind::SelectionStart;
833    let mut grab = drag_bias
834        .get()
835        .unwrap_or_else(|| HandleGrabOffset::begin_for(handle_tip_y, finger_y, drifts));
836    let bias = grab.track(finger_y);
837    drag_bias.set(Some(grab));
838    bias
839}
840
841/// Builds the drag handler for the collapsed cursor handle: moves the caret to
842/// the dragged position. `drag_bias` is the finger-to-line offset captured at
843/// the grab (see [`window_pos_to_offset`]).
844fn drag_caret_closure(
845    state: TextFieldState,
846    style: TextStyle,
847    controller: TextFieldHandleController,
848    drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
849) -> Rc<dyn Fn(Point)> {
850    Rc::new(move |window_pos: Point| {
851        let Some(metrics) = controller.metrics() else {
852            return;
853        };
854        let presentation = controller.presentation(state);
855        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
856        let offset = presentation.to_source(window_pos_to_offset(
857            presentation.text(),
858            &style,
859            &metrics,
860            window_pos,
861            bias,
862        ));
863        state.set_selection(TextRange::new(offset, offset));
864        crate::cursor_animation::suspend_cursor_blink();
865        crate::request_render_invalidation();
866    })
867}
868
869/// Builds the drag handler for a selection start/end handle: extends the
870/// selection to the dragged position while keeping the opposite edge fixed and
871/// never letting the edges cross. `drag_bias` as in [`drag_caret_closure`].
872fn drag_edge_closure(
873    dragged: HandleKind,
874    state: TextFieldState,
875    style: TextStyle,
876    controller: TextFieldHandleController,
877    drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
878) -> Rc<dyn Fn(Point)> {
879    Rc::new(move |window_pos: Point| {
880        let Some(metrics) = controller.metrics() else {
881            return;
882        };
883        let presentation = controller.presentation(state);
884        let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
885        let dragged_offset = presentation.to_source(window_pos_to_offset(
886            presentation.text(),
887            &style,
888            &metrics,
889            window_pos,
890            bias,
891        ));
892        let selection = state.selection();
893        let fixed_edge = match dragged {
894            HandleKind::SelectionStart => selection.max(),
895            _ => selection.min(),
896        };
897        let (min, max) = selection_after_handle_drag(
898            dragged,
899            fixed_edge,
900            dragged_offset,
901            state.with_text(str::len),
902        );
903        state.set_selection(TextRange::new(min, max));
904        crate::request_render_invalidation();
905    })
906}
907
908#[cfg(test)]
909#[path = "tests/basic_text_field_tests.rs"]
910mod tests;