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