1use 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#[derive(Debug, Default)]
24pub(super) struct TouchSelection {
25 range: Option<(usize, usize)>,
27 menu_open: bool,
28 drag: Option<EdgeDrag>,
29}
30
31impl<M: InputModeKind> InputBaseState<M> {
32 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 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 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 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 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 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 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 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 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 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 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 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 self.select_to_with_affinity(offset, line_end_affinity, cx);
207 } else {
208 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 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 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 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 if self.active_selection().is_empty() {
284 *self.active_selection_mut() = before;
285 return;
286 }
287 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 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 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 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 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 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 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 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 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 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 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 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 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 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}