Skip to main content

cranpose_ui/widgets/
basic_text_field.rs

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