Skip to main content

gpui_base/input/base/
touch.rs

1//! Touch selection for the input engine: long press, grab handles, edit menu.
2//!
3//! A long press selects the word under the finger and keeps following the
4//! finger while it stays down, like the double-click-and-drag it stands in
5//! for. Releasing opens the edit menu; from then on the selection carries a
6//! handle at each end which the styled layer draws and which drags through
7//! [`InputBaseState::begin_edge_drag`].
8//!
9//! The touch selection remembers the range it made. As soon as the selection
10//! is something else — the caret moved, text was typed, another cursor was
11//! added — the handles and the menu are gone, without anyone having to hide
12//! them. Only the touch gesture and the menu's own actions move the range and
13//! carry the touch selection along.
14
15use gpui::{Context, LongPressEvent, Pixels, Point, TouchPhase, Window, point};
16
17use super::{InputBaseState, InputModeKind};
18use crate::touch_selection::{
19    EdgeDrag, SelectionEdge, TouchSelectionSnapshot, caret_in_view, caret_line_box,
20};
21
22/// The selection a touch gesture made, and what it is doing now.
23#[derive(Debug, Default)]
24pub(super) struct TouchSelection {
25    /// The range the gesture left; `None` when no touch selection is live.
26    range: Option<(usize, usize)>,
27    menu_open: bool,
28    drag: Option<EdgeDrag>,
29}
30
31impl<M: InputModeKind> InputBaseState<M> {
32    /// The live touch selection, laid out for the handles and the edit menu.
33    ///
34    /// `None` when the last touch selection has since changed into something
35    /// else, or when its ends are not laid out (scrolled out of view).
36    pub fn touch_selection(&self) -> Option<TouchSelectionSnapshot> {
37        let range = self.touch_selection.range?;
38        let selection = self.active_selection();
39        if (selection.start, selection.end) != range {
40            return None;
41        }
42
43        let layout = self.last_layout.as_ref()?;
44        let line_height = layout.line_height;
45        let laid_out = layout.visible_range_offset.clone();
46        let origin = self.last_bounds?.origin;
47        let viewport = self.input_bounds;
48        // An end scrolled out of the input gets no handle. Its line may not
49        // even be laid out; it then stands just outside the viewport on its
50        // side, which is all the other end's drag needs to know about it.
51        let caret_box = |offset: usize, stand_in_y: Pixels| {
52            let (_, _, position) = self.line_and_position_for_offset(offset);
53            match position.filter(|_| laid_out.contains(&offset) || laid_out.end == offset) {
54                Some(position) => caret_line_box(origin + position, line_height),
55                None => caret_line_box(point(viewport.left(), stand_in_y), line_height),
56            }
57        };
58        let start = caret_box(range.0, viewport.top() - line_height);
59        let end = caret_box(range.1, viewport.bottom());
60        Some(
61            TouchSelectionSnapshot::new(start, end)
62                .with_edge_visible(SelectionEdge::Start, caret_in_view(start, viewport))
63                .with_edge_visible(SelectionEdge::End, caret_in_view(end, viewport))
64                .with_menu_open(self.touch_selection.menu_open)
65                .with_dragging(self.touch_selection.drag.map(|drag| drag.edge())),
66        )
67    }
68
69    /// Records the current selection as the one the touch gesture made.
70    fn retain_touch_selection(&mut self) {
71        let selection = self.active_selection();
72        self.touch_selection.range = Some((selection.start, selection.end));
73    }
74
75    /// Keeps the current selection as a touch selection, with the menu open
76    /// over it: what a double tap's word selection asks for.
77    pub(super) fn keep_touch_selection(&mut self, cx: &mut Context<Self>) {
78        self.retain_touch_selection();
79        self.touch_selection.menu_open = true;
80        self.touch_selection.drag = None;
81        cx.notify();
82    }
83
84    /// Drops the handles and the edit menu. Called where the selection is
85    /// about to be moved by something other than the gesture.
86    pub(super) fn dismiss_touch_selection(&mut self, cx: &mut Context<Self>) {
87        if self.touch_selection.range.is_none() {
88            return;
89        }
90        self.touch_selection = TouchSelection::default();
91        cx.notify();
92    }
93
94    /// Closes the edit menu and keeps the selection with its handles.
95    ///
96    /// The menu's own actions end here: Copy has done its work, Select All
97    /// reopens the menu over the new range through [`Self::select_all`].
98    pub fn close_edit_menu(&mut self, cx: &mut Context<Self>) {
99        if !self.touch_selection.menu_open {
100            return;
101        }
102        self.touch_selection.menu_open = false;
103        cx.notify();
104    }
105
106    /// Reopens the edit menu when `position` is on the touch selection, the
107    /// way a tap on selected text asks for it. Returns whether it did.
108    pub(super) fn reopen_edit_menu_at(
109        &mut self,
110        position: Point<Pixels>,
111        cx: &mut Context<Self>,
112    ) -> bool {
113        let Some(snapshot) = self.touch_selection() else {
114            return false;
115        };
116        if snapshot.is_empty() {
117            return false;
118        }
119        let (offset, _, _) = self.resolve_mouse_position(position);
120        let selection = self.active_selection();
121        if offset <= selection.start || offset >= selection.end {
122            return false;
123        }
124        self.touch_selection.menu_open = true;
125        cx.notify();
126        true
127    }
128
129    /// Steps the menu aside while the content scrolls under a finger, and
130    /// brings it back over the handles once the finger lifts.
131    pub(super) fn edit_menu_on_scroll(&mut self, phase: TouchPhase, cx: &mut Context<Self>) {
132        if self.touch_selection.range.is_none() {
133            return;
134        }
135        match phase {
136            TouchPhase::Ended | TouchPhase::Cancelled => {
137                if !self.touch_selection.menu_open {
138                    self.touch_selection.menu_open = true;
139                    cx.notify();
140                }
141            }
142            _ => self.close_edit_menu(cx),
143        }
144    }
145
146    /// Selects everything and, when a touch selection is live, keeps the
147    /// handles and the edit menu over the new range.
148    pub fn select_all_from_edit_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
149        let touch = self.touch_selection.range.is_some();
150        self.select_all(window, cx);
151        if touch {
152            self.retain_touch_selection();
153            self.touch_selection.menu_open = true;
154            cx.notify();
155        }
156    }
157
158    /// Handles one phase of a long press inside the input.
159    ///
160    /// Returns whether the started phase was claimed; the caller then captures
161    /// the gesture so the moves keep coming even after the finger leaves the
162    /// input.
163    pub(super) fn on_long_press(
164        &mut self,
165        event: &LongPressEvent,
166        window: &mut Window,
167        cx: &mut Context<Self>,
168    ) -> bool {
169        match event.phase {
170            TouchPhase::Started => {
171                if self.disabled {
172                    return false;
173                }
174                if !self.focus_handle.is_focused(window) {
175                    window.focus(&self.focus_handle, cx);
176                }
177                // The input selects on its own; keep the window text selection
178                // from starting a drag under it.
179                crate::GlobalState::suppress_text_selection(cx);
180                self.undo_manager.break_transaction_coalescing();
181                M::clear_inline_completion(self, cx);
182                self.touch_selection = TouchSelection::default();
183
184                let (offset, line_end_affinity, _) =
185                    self.resolve_mouse_position(event.start_position);
186                self.selections.remove_all_but_active();
187                self.set_cursor_to(offset);
188                self.select_word(offset, window, cx);
189                let pressed_word = !self.active_selection().is_empty()
190                    && !self.selected_text().chars().all(char::is_whitespace);
191                if !pressed_word {
192                    // Whitespace or an empty field: the press places the caret,
193                    // and the menu offers Paste and Select All.
194                    self.move_to_with_affinity(offset, None, line_end_affinity, cx);
195                    self.selected_word_range = None;
196                }
197                self.selecting = true;
198                self.retain_touch_selection();
199                cx.notify();
200                true
201            }
202            TouchPhase::Moved => {
203                let (offset, line_end_affinity, _) = self.resolve_mouse_position(event.position);
204                if self.selected_word_range.is_some() {
205                    // The press took a word; the sweep grows it a word at a time.
206                    self.select_to_with_affinity(offset, line_end_affinity, cx);
207                } else {
208                    // The press placed the caret; the sweep carries it.
209                    self.move_to_with_affinity(offset, None, line_end_affinity, cx);
210                }
211                self.retain_touch_selection();
212                true
213            }
214            TouchPhase::Ended | TouchPhase::Cancelled => {
215                self.selecting = false;
216                self.selected_word_range = None;
217                if self.touch_selection.range.is_some() {
218                    self.touch_selection.menu_open = true;
219                }
220                cx.notify();
221                true
222            }
223        }
224    }
225
226    /// Starts dragging one end of the touch selection from `finger`.
227    ///
228    /// The other end stays where it is. The menu closes for the duration of the
229    /// drag and reopens on [`Self::end_edge_drag`].
230    pub fn begin_edge_drag(
231        &mut self,
232        edge: SelectionEdge,
233        finger: Point<Pixels>,
234        cx: &mut Context<Self>,
235    ) {
236        let Some(snapshot) = self.touch_selection() else {
237            return;
238        };
239        self.undo_manager.break_transaction_coalescing();
240        self.selected_word_range = None;
241        self.active_selection_mut().reversed = edge == SelectionEdge::Start;
242        self.touch_selection.drag = Some(EdgeDrag::begin(edge, snapshot.edge(edge), finger));
243        self.touch_selection.menu_open = false;
244        cx.notify();
245    }
246
247    /// Moves the dragged end to the text under `finger`.
248    ///
249    /// Past the top or bottom of a multi-line input the content scrolls under
250    /// the finger, so the end can reach text that was out of view.
251    pub fn update_edge_drag(&mut self, finger: Point<Pixels>, cx: &mut Context<Self>) {
252        let Some(drag) = self.touch_selection.drag else {
253            return;
254        };
255        let position = drag.text_position(finger);
256        self.extend_edge_drag_to(position, cx);
257
258        if self.is_single_line() {
259            return;
260        }
261        self.auto_scroll.last_drag_position = Some(position);
262        let delta = crate::AutoScroll::compute_delta(position.y, self.input_bounds);
263        // Input's ScrollHandle uses negative-y-is-down; negate the positive-towards-bottom delta.
264        let scroll_delta = delta.map(|delta| -delta);
265        self.auto_scroll.set(scroll_delta, cx, |delta, state, cx| {
266            let current = state.scroll_handle.offset();
267            state.update_scroll_offset(Some(point(current.x, current.y + delta)), cx);
268            if let Some(position) = state.auto_scroll.last_drag_position {
269                state.extend_edge_drag_to(position, cx);
270            }
271        });
272    }
273
274    fn extend_edge_drag_to(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
275        if self.touch_selection.drag.is_none() {
276            return;
277        }
278        let (offset, line_end_affinity, _) = self.resolve_mouse_position(position);
279        let before = *self.active_selection();
280        self.select_to_with_affinity(offset, line_end_affinity, cx);
281        // A handle never collapses the selection: at the other end it stops,
282        // and the finger has to pass that end to swap the two.
283        if self.active_selection().is_empty() {
284            *self.active_selection_mut() = before;
285            return;
286        }
287        // Dragging one end past the other swaps them: the selection now runs
288        // the other way and the finger holds what became the other handle.
289        let edge = if self.active_selection().reversed {
290            SelectionEdge::Start
291        } else {
292            SelectionEdge::End
293        };
294        if let Some(drag) = self.touch_selection.drag.as_mut() {
295            drag.set_edge(edge);
296        }
297        self.retain_touch_selection();
298        cx.notify();
299    }
300
301    /// Ends the handle drag and reopens the edit menu.
302    pub fn end_edge_drag(&mut self, cx: &mut Context<Self>) {
303        if self.touch_selection.drag.take().is_none() {
304            return;
305        }
306        self.auto_scroll.stop();
307        if self.active_selection().is_empty() {
308            self.active_selection_mut().reversed = false;
309        }
310        self.touch_selection.menu_open = true;
311        cx.notify();
312    }
313
314    #[cfg(test)]
315    pub(super) fn is_edit_menu_open(&self) -> bool {
316        self.touch_selection.menu_open
317    }
318
319    #[cfg(test)]
320    pub(super) fn touch_selection_range(&self) -> Option<std::ops::Range<usize>> {
321        self.touch_selection.range.map(|(start, end)| start..end)
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use gpui::{
328        AppContext as _, Context, Entity, IntoElement, LongPressEvent, MouseButton,
329        ParentElement as _, Render, Styled as _, TestAppContext, TouchPhase, VisualTestContext,
330        Window, div, point, px,
331    };
332
333    use crate::input::{InputState, TextareaState};
334    use crate::touch_selection::SelectionEdge;
335
336    struct TouchRoot(Entity<InputState>);
337
338    impl Render for TouchRoot {
339        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
340            div().size_full().child(self.0.clone())
341        }
342    }
343
344    struct TextareaRoot(Entity<TextareaState>);
345
346    impl Render for TextareaRoot {
347        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
348            // Short enough that forty lines scroll.
349            div().w(px(300.)).h(px(80.)).child(self.0.clone())
350        }
351    }
352
353    fn open_input<'a>(
354        cx: &'a mut TestAppContext,
355        value: &str,
356    ) -> (Entity<InputState>, &'a mut VisualTestContext) {
357        cx.update(crate::init);
358        let value = value.to_string();
359        let (root, cx) = cx.add_window_view(move |window, cx| {
360            let input = cx.new(|cx| {
361                let mut state = InputState::new(window, cx);
362                state.set_value(value, window, cx);
363                state
364            });
365            TouchRoot(input)
366        });
367        let input = root.read_with(cx, |root, _| root.0.clone());
368        cx.run_until_parked();
369        cx.update(|window, cx| {
370            let _ = window.draw(cx);
371        });
372        (input, cx)
373    }
374
375    fn long_press(
376        cx: &mut VisualTestContext,
377        phase: TouchPhase,
378        start: (f32, f32),
379        at: (f32, f32),
380    ) {
381        cx.simulate_event(LongPressEvent {
382            phase,
383            start_position: point(px(start.0), px(start.1)),
384            position: point(px(at.0), px(at.1)),
385        });
386        cx.update(|window, cx| {
387            let _ = window.draw(cx);
388        });
389    }
390
391    /// The window position of the caret before `offset`, in the input's line.
392    fn caret_at(input: &Entity<InputState>, cx: &VisualTestContext, offset: usize) -> (f32, f32) {
393        input.read_with(cx, |state, _| {
394            let (_, _, position) = state.line_and_position_for_offset(offset);
395            let position = state.last_bounds.unwrap().origin + position.unwrap();
396            let line_height = state.last_layout.as_ref().unwrap().line_height;
397            (position.x.into(), (position.y + line_height * 0.5).into())
398        })
399    }
400
401    #[gpui::test]
402    fn long_press_selects_word_then_release_opens_menu(cx: &mut TestAppContext) {
403        let (input, cx) = open_input(cx, "quick select value");
404        let start = caret_at(&input, cx, 8);
405        long_press(cx, TouchPhase::Started, start, start);
406        input.read_with(cx, |state, _| {
407            assert_eq!(state.selected_text().to_string(), "select");
408            assert!(!state.is_edit_menu_open());
409            let snapshot = state.touch_selection().expect("touch selection is live");
410            assert!(!snapshot.is_empty());
411            assert!(!snapshot.is_menu_open());
412        });
413
414        let end = caret_at(&input, cx, 18);
415        long_press(cx, TouchPhase::Moved, start, end);
416        input.read_with(cx, |state, _| {
417            assert_eq!(state.selected_text().to_string(), "select value");
418        });
419
420        long_press(cx, TouchPhase::Ended, start, end);
421        input.read_with(cx, |state, _| {
422            assert_eq!(state.selected_text().to_string(), "select value");
423            assert!(state.is_edit_menu_open());
424            let snapshot = state.touch_selection().expect("touch selection is live");
425            assert!(snapshot.is_menu_open());
426            assert!(snapshot.start().left() < snapshot.end().left());
427        });
428    }
429
430    #[gpui::test]
431    fn double_tap_selects_word_with_handles_and_menu(cx: &mut TestAppContext) {
432        let (input, cx) = open_input(cx, "quick select value");
433        let at = caret_at(&input, cx, 8);
434        let position = point(px(at.0), px(at.1));
435        // A tap arrives as mouse events; the touch that began it is what
436        // tells the input they came from a finger.
437        cx.update(|_, cx| crate::GlobalState::note_touch(cx));
438        for click_count in [1, 2] {
439            cx.simulate_event(gpui::MouseDownEvent {
440                position,
441                button: MouseButton::Left,
442                modifiers: Default::default(),
443                click_count,
444                first_mouse: false,
445            });
446            cx.simulate_event(gpui::MouseUpEvent {
447                position,
448                button: MouseButton::Left,
449                modifiers: Default::default(),
450                click_count,
451            });
452        }
453        cx.update(|window, cx| {
454            let _ = window.draw(cx);
455        });
456        input.read_with(cx, |state, _| {
457            assert_eq!(state.selected_text().to_string(), "select");
458            let snapshot = state
459                .touch_selection()
460                .expect("a double tap is a touch selection");
461            assert!(snapshot.is_menu_open());
462        });
463    }
464
465    #[gpui::test]
466    fn long_press_on_empty_input_places_caret_with_menu(cx: &mut TestAppContext) {
467        let (input, cx) = open_input(cx, "");
468        long_press(cx, TouchPhase::Started, (20., 10.), (20., 10.));
469        long_press(cx, TouchPhase::Ended, (20., 10.), (20., 10.));
470        input.read_with(cx, |state, _| {
471            assert_eq!(state.selected_range(), 0..0);
472            let snapshot = state.touch_selection().expect("caret still gets a menu");
473            assert!(snapshot.is_empty());
474            assert!(snapshot.is_menu_open());
475        });
476    }
477
478    #[gpui::test]
479    fn dragging_a_handle_moves_that_end_only(cx: &mut TestAppContext) {
480        let (input, cx) = open_input(cx, "quick select value");
481        let start = caret_at(&input, cx, 8);
482        long_press(cx, TouchPhase::Started, start, start);
483        long_press(cx, TouchPhase::Ended, start, start);
484
485        // The finger holds the end knob, which hangs below the line.
486        let end_caret = caret_at(&input, cx, 12);
487        let finger = (end_caret.0, end_caret.1 + 20.);
488        cx.update(|_, cx| {
489            input.update(cx, |state, cx| {
490                state.begin_edge_drag(SelectionEdge::End, point(px(finger.0), px(finger.1)), cx);
491            });
492        });
493        input.read_with(cx, |state, _| {
494            let snapshot = state.touch_selection().unwrap();
495            assert_eq!(snapshot.dragging(), Some(SelectionEdge::End));
496            assert!(!snapshot.is_menu_open());
497        });
498
499        let target = caret_at(&input, cx, 18);
500        cx.update(|_, cx| {
501            input.update(cx, |state, cx| {
502                state.update_edge_drag(point(px(target.0), px(target.1 + 20.)), cx);
503            });
504        });
505        input.read_with(cx, |state, _| {
506            assert_eq!(state.selected_text().to_string(), "select value");
507        });
508
509        // Pull the start handle past the end: the ends swap, the finger keeps
510        // its handle.
511        cx.update(|_, cx| {
512            input.update(cx, |state, cx| {
513                state.end_edge_drag(cx);
514                assert!(state.is_edit_menu_open());
515                let start_caret = state.touch_selection().unwrap().start();
516                state.begin_edge_drag(SelectionEdge::Start, start_caret.origin, cx);
517            });
518        });
519        let target = caret_at(&input, cx, 0);
520        cx.update(|_, cx| {
521            input.update(cx, |state, cx| {
522                state.update_edge_drag(point(px(target.0), px(target.1)), cx);
523            });
524        });
525        input.read_with(cx, |state, _| {
526            assert_eq!(state.selected_text().to_string(), "quick select value");
527            assert_eq!(state.touch_selection_range(), Some(0..18));
528        });
529    }
530
531    #[gpui::test]
532    fn dragging_one_handle_past_the_other_swaps_them(cx: &mut TestAppContext) {
533        let (input, cx) = open_input(cx, "quick select value");
534        let start = caret_at(&input, cx, 8);
535        long_press(cx, TouchPhase::Started, start, start);
536        long_press(cx, TouchPhase::Ended, start, start);
537        input.read_with(cx, |state, _| {
538            assert_eq!(state.selected_text().to_string(), "select");
539        });
540
541        // Take the start handle and pull it past the end of "select" to the
542        // end of the text: the finger now holds the end handle, and the
543        // selection runs from the old end forward.
544        let start_caret = caret_at(&input, cx, 6);
545        cx.update(|_, cx| {
546            input.update(cx, |state, cx| {
547                state.begin_edge_drag(
548                    SelectionEdge::Start,
549                    point(px(start_caret.0), px(start_caret.1)),
550                    cx,
551                );
552            });
553        });
554        let target = caret_at(&input, cx, 18);
555        cx.update(|_, cx| {
556            input.update(cx, |state, cx| {
557                state.update_edge_drag(point(px(target.0), px(target.1)), cx);
558            });
559        });
560        input.read_with(cx, |state, _| {
561            assert_eq!(state.selected_text().to_string(), " value");
562            assert_eq!(
563                state.touch_selection().unwrap().dragging(),
564                Some(SelectionEdge::End)
565            );
566        });
567
568        // And back across again: it is the start handle once more.
569        let target = caret_at(&input, cx, 0);
570        cx.update(|_, cx| {
571            input.update(cx, |state, cx| {
572                state.update_edge_drag(point(px(target.0), px(target.1)), cx);
573                state.end_edge_drag(cx);
574            });
575        });
576        input.read_with(cx, |state, _| {
577            assert_eq!(state.selected_text().to_string(), "quick select");
578            assert!(state.is_edit_menu_open());
579        });
580    }
581
582    #[gpui::test]
583    fn touch_selection_goes_away_when_something_else_moves_the_selection(cx: &mut TestAppContext) {
584        let (input, cx) = open_input(cx, "quick select value");
585        let start = caret_at(&input, cx, 2);
586        long_press(cx, TouchPhase::Started, start, start);
587        long_press(cx, TouchPhase::Ended, start, start);
588        input.read_with(cx, |state, _| assert!(state.touch_selection().is_some()));
589
590        // Select All from the menu keeps it, over the new range.
591        cx.update(|window, cx| {
592            input.update(cx, |state, cx| state.select_all_from_edit_menu(window, cx));
593        });
594        input.read_with(cx, |state, _| {
595            assert_eq!(state.selected_range(), 0..18);
596            assert!(state.touch_selection().is_some());
597            assert!(state.is_edit_menu_open());
598        });
599
600        // Copy closes the menu but keeps the handles.
601        cx.update(|_, cx| input.update(cx, |state, cx| state.close_edit_menu(cx)));
602        input.read_with(cx, |state, _| {
603            let snapshot = state.touch_selection().unwrap();
604            assert!(!snapshot.is_menu_open());
605        });
606
607        // Typing replaces the selection; nothing is left to hold a handle.
608        cx.update(|window, cx| {
609            input.update(cx, |state, cx| {
610                state.replace_text_in_range_silent(None, "x", window, cx);
611            });
612        });
613        input.read_with(cx, |state, _| assert!(state.touch_selection().is_none()));
614
615        // A press elsewhere drops it too.
616        long_press(cx, TouchPhase::Started, start, start);
617        long_press(cx, TouchPhase::Ended, start, start);
618        input.read_with(cx, |state, _| assert!(state.touch_selection().is_some()));
619        let elsewhere = caret_at(&input, cx, 0);
620        cx.simulate_mouse_down(
621            point(px(elsewhere.0), px(elsewhere.1)),
622            MouseButton::Left,
623            Default::default(),
624        );
625        input.read_with(cx, |state, _| assert!(state.touch_selection().is_none()));
626    }
627
628    #[gpui::test]
629    fn scrolling_closes_the_menu_and_keeps_the_handles(cx: &mut TestAppContext) {
630        cx.update(crate::init);
631        let (root, cx) = cx.add_window_view(|window, cx| {
632            let textarea = cx.new(|cx| {
633                let mut state = TextareaState::new(window, cx);
634                let text = (0..40).map(|ix| format!("line {ix}")).collect::<Vec<_>>();
635                state.set_value(text.join("\n"), window, cx);
636                state
637            });
638            TextareaRoot(textarea)
639        });
640        let textarea = root.read_with(cx, |root, _| root.0.clone());
641        cx.run_until_parked();
642        cx.update(|window, cx| {
643            let _ = window.draw(cx);
644        });
645        let start = textarea.read_with(cx, |state, _| {
646            let (_, _, position) = state.line_and_position_for_offset(2);
647            let position = state.last_bounds.unwrap().origin + position.unwrap();
648            (f32::from(position.x), f32::from(position.y) + 4.)
649        });
650        long_press(cx, TouchPhase::Started, start, start);
651        long_press(cx, TouchPhase::Ended, start, start);
652        textarea.read_with(cx, |state, _| {
653            assert_eq!(state.selected_text().to_string(), "line");
654            assert!(state.touch_selection().unwrap().is_menu_open());
655        });
656
657        cx.update(|_, cx| {
658            textarea.update(cx, |state, cx| {
659                state.set_scroll_offset(point(px(0.), px(-8.)), cx);
660                state.close_edit_menu(cx);
661            });
662        });
663        cx.update(|window, cx| {
664            let _ = window.draw(cx);
665        });
666        textarea.read_with(cx, |state, _| {
667            let snapshot = state.touch_selection().expect("handles follow the text");
668            assert!(!snapshot.is_menu_open());
669            assert_eq!(state.selected_text().to_string(), "line");
670        });
671
672        // Scrolled far enough, the selection leaves the viewport: no handle
673        // for it, and no menu anchored to nothing.
674        cx.update(|_, cx| {
675            textarea.update(cx, |state, cx| {
676                state.set_scroll_offset(point(px(0.), px(-600.)), cx);
677            });
678        });
679        cx.update(|window, cx| {
680            let _ = window.draw(cx);
681        });
682        textarea.read_with(cx, |state, _| {
683            let snapshot = state
684                .touch_selection()
685                .expect("the selection is still the touch one");
686            assert!(!snapshot.is_edge_visible(SelectionEdge::Start));
687            assert!(!snapshot.is_edge_visible(SelectionEdge::End));
688            assert_eq!(snapshot.bounds(), None);
689        });
690    }
691}