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