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        let caret = caret_at(&input, cx, 0);
469        long_press(cx, TouchPhase::Started, caret, caret);
470        long_press(cx, TouchPhase::Ended, caret, caret);
471        input.read_with(cx, |state, _| {
472            assert_eq!(state.selected_range(), 0..0);
473            let snapshot = state.touch_selection().expect("caret still gets a menu");
474            assert!(snapshot.is_empty());
475            assert!(snapshot.is_menu_open());
476        });
477    }
478
479    #[gpui::test]
480    fn dragging_a_handle_moves_that_end_only(cx: &mut TestAppContext) {
481        let (input, cx) = open_input(cx, "quick select value");
482        let start = caret_at(&input, cx, 8);
483        long_press(cx, TouchPhase::Started, start, start);
484        long_press(cx, TouchPhase::Ended, start, start);
485
486        // The finger holds the end knob, which hangs below the line.
487        let end_caret = caret_at(&input, cx, 12);
488        let finger = (end_caret.0, end_caret.1 + 20.);
489        cx.update(|_, cx| {
490            input.update(cx, |state, cx| {
491                state.begin_edge_drag(SelectionEdge::End, point(px(finger.0), px(finger.1)), cx);
492            });
493        });
494        input.read_with(cx, |state, _| {
495            let snapshot = state.touch_selection().unwrap();
496            assert_eq!(snapshot.dragging(), Some(SelectionEdge::End));
497            assert!(!snapshot.is_menu_open());
498        });
499
500        let target = caret_at(&input, cx, 18);
501        cx.update(|_, cx| {
502            input.update(cx, |state, cx| {
503                state.update_edge_drag(point(px(target.0), px(target.1 + 20.)), cx);
504            });
505        });
506        input.read_with(cx, |state, _| {
507            assert_eq!(state.selected_text().to_string(), "select value");
508        });
509
510        // Pull the start handle past the end: the ends swap, the finger keeps
511        // its handle.
512        cx.update(|_, cx| {
513            input.update(cx, |state, cx| {
514                state.end_edge_drag(cx);
515                assert!(state.is_edit_menu_open());
516                let start_caret = state.touch_selection().unwrap().start();
517                state.begin_edge_drag(SelectionEdge::Start, start_caret.origin, cx);
518            });
519        });
520        let target = caret_at(&input, cx, 0);
521        cx.update(|_, cx| {
522            input.update(cx, |state, cx| {
523                state.update_edge_drag(point(px(target.0), px(target.1)), cx);
524            });
525        });
526        input.read_with(cx, |state, _| {
527            assert_eq!(state.selected_text().to_string(), "quick select value");
528            assert_eq!(state.touch_selection_range(), Some(0..18));
529        });
530    }
531
532    #[gpui::test]
533    fn dragging_one_handle_past_the_other_swaps_them(cx: &mut TestAppContext) {
534        let (input, cx) = open_input(cx, "quick select value");
535        let start = caret_at(&input, cx, 8);
536        long_press(cx, TouchPhase::Started, start, start);
537        long_press(cx, TouchPhase::Ended, start, start);
538        input.read_with(cx, |state, _| {
539            assert_eq!(state.selected_text().to_string(), "select");
540        });
541
542        // Take the start handle and pull it past the end of "select" to the
543        // end of the text: the finger now holds the end handle, and the
544        // selection runs from the old end forward.
545        let start_caret = caret_at(&input, cx, 6);
546        cx.update(|_, cx| {
547            input.update(cx, |state, cx| {
548                state.begin_edge_drag(
549                    SelectionEdge::Start,
550                    point(px(start_caret.0), px(start_caret.1)),
551                    cx,
552                );
553            });
554        });
555        let target = caret_at(&input, cx, 18);
556        cx.update(|_, cx| {
557            input.update(cx, |state, cx| {
558                state.update_edge_drag(point(px(target.0), px(target.1)), cx);
559            });
560        });
561        input.read_with(cx, |state, _| {
562            assert_eq!(state.selected_text().to_string(), " value");
563            assert_eq!(
564                state.touch_selection().unwrap().dragging(),
565                Some(SelectionEdge::End)
566            );
567        });
568
569        // And back across again: it is the start handle once more.
570        let target = caret_at(&input, cx, 0);
571        cx.update(|_, cx| {
572            input.update(cx, |state, cx| {
573                state.update_edge_drag(point(px(target.0), px(target.1)), cx);
574                state.end_edge_drag(cx);
575            });
576        });
577        input.read_with(cx, |state, _| {
578            assert_eq!(state.selected_text().to_string(), "quick select");
579            assert!(state.is_edit_menu_open());
580        });
581    }
582
583    #[gpui::test]
584    fn touch_selection_goes_away_when_something_else_moves_the_selection(cx: &mut TestAppContext) {
585        let (input, cx) = open_input(cx, "quick select value");
586        let start = caret_at(&input, cx, 2);
587        long_press(cx, TouchPhase::Started, start, start);
588        long_press(cx, TouchPhase::Ended, start, start);
589        input.read_with(cx, |state, _| assert!(state.touch_selection().is_some()));
590
591        // Select All from the menu keeps it, over the new range.
592        cx.update(|window, cx| {
593            input.update(cx, |state, cx| state.select_all_from_edit_menu(window, cx));
594        });
595        input.read_with(cx, |state, _| {
596            assert_eq!(state.selected_range(), 0..18);
597            assert!(state.touch_selection().is_some());
598            assert!(state.is_edit_menu_open());
599        });
600
601        // Copy closes the menu but keeps the handles.
602        cx.update(|_, cx| input.update(cx, |state, cx| state.close_edit_menu(cx)));
603        input.read_with(cx, |state, _| {
604            let snapshot = state.touch_selection().unwrap();
605            assert!(!snapshot.is_menu_open());
606        });
607
608        // Typing replaces the selection; nothing is left to hold a handle.
609        cx.update(|window, cx| {
610            input.update(cx, |state, cx| {
611                state.replace_text_in_range_silent(None, "x", window, cx);
612            });
613        });
614        input.read_with(cx, |state, _| assert!(state.touch_selection().is_none()));
615
616        // A press elsewhere drops it too.
617        long_press(cx, TouchPhase::Started, start, start);
618        long_press(cx, TouchPhase::Ended, start, start);
619        input.read_with(cx, |state, _| assert!(state.touch_selection().is_some()));
620        let elsewhere = caret_at(&input, cx, 0);
621        cx.simulate_mouse_down(
622            point(px(elsewhere.0), px(elsewhere.1)),
623            MouseButton::Left,
624            Default::default(),
625        );
626        input.read_with(cx, |state, _| assert!(state.touch_selection().is_none()));
627    }
628
629    #[gpui::test]
630    fn scrolling_closes_the_menu_and_keeps_the_handles(cx: &mut TestAppContext) {
631        cx.update(crate::init);
632        let (root, cx) = cx.add_window_view(|window, cx| {
633            let textarea = cx.new(|cx| {
634                let mut state = TextareaState::new(window, cx);
635                let text = (0..40).map(|ix| format!("line {ix}")).collect::<Vec<_>>();
636                state.set_value(text.join("\n"), window, cx);
637                state
638            });
639            TextareaRoot(textarea)
640        });
641        let textarea = root.read_with(cx, |root, _| root.0.clone());
642        cx.run_until_parked();
643        cx.update(|window, cx| {
644            let _ = window.draw(cx);
645        });
646        let start = textarea.read_with(cx, |state, _| {
647            let (_, _, position) = state.line_and_position_for_offset(2);
648            let position = state.last_bounds.unwrap().origin + position.unwrap();
649            (f32::from(position.x), f32::from(position.y) + 4.)
650        });
651        long_press(cx, TouchPhase::Started, start, start);
652        long_press(cx, TouchPhase::Ended, start, start);
653        textarea.read_with(cx, |state, _| {
654            assert_eq!(state.selected_text().to_string(), "line");
655            assert!(state.touch_selection().unwrap().is_menu_open());
656        });
657
658        cx.update(|_, cx| {
659            textarea.update(cx, |state, cx| {
660                state.set_scroll_offset(point(px(0.), px(-8.)), cx);
661                state.close_edit_menu(cx);
662            });
663        });
664        cx.update(|window, cx| {
665            let _ = window.draw(cx);
666        });
667        textarea.read_with(cx, |state, _| {
668            let snapshot = state.touch_selection().expect("handles follow the text");
669            assert!(!snapshot.is_menu_open());
670            assert_eq!(state.selected_text().to_string(), "line");
671        });
672
673        // Scrolled far enough, the selection leaves the viewport: no handle
674        // for it, and no menu anchored to nothing.
675        cx.update(|_, cx| {
676            textarea.update(cx, |state, cx| {
677                state.set_scroll_offset(point(px(0.), px(-600.)), cx);
678            });
679        });
680        cx.update(|window, cx| {
681            let _ = window.draw(cx);
682        });
683        textarea.read_with(cx, |state, _| {
684            let snapshot = state
685                .touch_selection()
686                .expect("the selection is still the touch one");
687            assert!(!snapshot.is_edge_visible(SelectionEdge::Start));
688            assert!(!snapshot.is_edge_visible(SelectionEdge::End));
689            assert_eq!(snapshot.bounds(), None);
690        });
691    }
692}