1#![forbid(unsafe_code)]
2use crate::grid::Grid;
30use crate::scrollback::Scrollback;
31use crate::selection::{BufferPos, CopyOptions, Selection};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum SelectionGranularity {
36 Character,
38 Word,
40 Line,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum SelectionShape {
47 Linear,
49 Rectangular,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum SelectionPhase {
56 None,
58 Selecting,
60 Active,
62}
63
64#[derive(Debug, Clone)]
70pub struct SelectionState {
71 phase: SelectionPhase,
73 anchor: Option<BufferPos>,
75 selection: Option<Selection>,
77 granularity: SelectionGranularity,
79 shape: SelectionShape,
81}
82
83impl Default for SelectionState {
84 fn default() -> Self {
85 Self::new()
86 }
87}
88
89impl SelectionState {
90 #[must_use]
92 pub const fn new() -> Self {
93 Self {
94 phase: SelectionPhase::None,
95 anchor: None,
96 selection: None,
97 granularity: SelectionGranularity::Character,
98 shape: SelectionShape::Linear,
99 }
100 }
101
102 #[must_use]
108 pub fn phase(&self) -> SelectionPhase {
109 self.phase
110 }
111
112 #[must_use]
116 pub fn current_selection(&self) -> Option<Selection> {
117 self.selection.map(|s| s.normalized())
118 }
119
120 #[must_use]
122 pub fn raw_selection(&self) -> Option<Selection> {
123 self.selection
124 }
125
126 #[must_use]
128 pub fn anchor(&self) -> Option<BufferPos> {
129 self.anchor
130 }
131
132 #[must_use]
134 pub fn granularity(&self) -> SelectionGranularity {
135 self.granularity
136 }
137
138 #[must_use]
140 pub fn shape(&self) -> SelectionShape {
141 self.shape
142 }
143
144 #[must_use]
146 pub fn has_selection(&self) -> bool {
147 self.selection.is_some()
148 }
149
150 pub fn start(&mut self, pos: BufferPos, granularity: SelectionGranularity) {
159 self.anchor = Some(pos);
160 self.selection = Some(Selection::new(pos, pos));
161 self.granularity = granularity;
162 self.phase = SelectionPhase::Selecting;
163 }
164
165 pub fn start_with_shape(
167 &mut self,
168 pos: BufferPos,
169 granularity: SelectionGranularity,
170 shape: SelectionShape,
171 ) {
172 self.shape = shape;
173 self.start(pos, granularity);
174 }
175
176 pub fn drag(&mut self, pos: BufferPos) {
181 if self.phase != SelectionPhase::Selecting {
182 return;
183 }
184 if let Some(anchor) = self.anchor {
185 self.selection = Some(Selection::new(anchor, pos));
186 }
187 }
188
189 pub fn drag_expanded(&mut self, pos: BufferPos, grid: &Grid, scrollback: &Scrollback) {
194 if self.phase != SelectionPhase::Selecting {
195 return;
196 }
197 let Some(anchor) = self.anchor else { return };
198
199 match self.granularity {
200 SelectionGranularity::Character => {
201 self.selection = Some(Selection::new(anchor, pos));
202 }
203 SelectionGranularity::Word => {
204 let anchor_word = Selection::word_at(anchor, grid, scrollback);
205 let pos_word = Selection::word_at(pos, grid, scrollback);
206 let anchor_norm = anchor_word.normalized();
207 let pos_norm = pos_word.normalized();
208 let start = if (anchor_norm.start.line, anchor_norm.start.col)
210 <= (pos_norm.start.line, pos_norm.start.col)
211 {
212 anchor_norm.start
213 } else {
214 pos_norm.start
215 };
216 let end = if (anchor_norm.end.line, anchor_norm.end.col)
217 >= (pos_norm.end.line, pos_norm.end.col)
218 {
219 anchor_norm.end
220 } else {
221 pos_norm.end
222 };
223 self.selection = Some(Selection::new(start, end));
224 }
225 SelectionGranularity::Line => {
226 let anchor_line = Selection::line_at(anchor.line, grid, scrollback);
227 let pos_line = Selection::line_at(pos.line, grid, scrollback);
228 let a = anchor_line.normalized();
229 let p = pos_line.normalized();
230 let start = if a.start.line <= p.start.line {
231 a.start
232 } else {
233 p.start
234 };
235 let end = if a.end.line >= p.end.line {
236 a.end
237 } else {
238 p.end
239 };
240 self.selection = Some(Selection::new(start, end));
241 }
242 }
243 }
244
245 pub fn commit(&mut self) {
250 if self.phase == SelectionPhase::Selecting {
251 self.phase = SelectionPhase::Active;
252 }
253 }
254
255 pub fn cancel(&mut self) {
259 self.phase = SelectionPhase::None;
260 self.anchor = None;
261 self.selection = None;
262 }
263
264 pub fn toggle_shape(&mut self) {
268 self.shape = match self.shape {
269 SelectionShape::Linear => SelectionShape::Rectangular,
270 SelectionShape::Rectangular => SelectionShape::Linear,
271 };
272 }
273
274 #[must_use]
282 pub fn contains(&self, line: u32, col: u16) -> bool {
283 let Some(sel) = self.current_selection() else {
284 return false;
285 };
286
287 match self.shape {
288 SelectionShape::Linear => {
289 if line < sel.start.line || line > sel.end.line {
290 return false;
291 }
292 if sel.start.line == sel.end.line {
293 col >= sel.start.col && col <= sel.end.col
295 } else if line == sel.start.line {
296 col >= sel.start.col
297 } else if line == sel.end.line {
298 col <= sel.end.col
299 } else {
300 true }
302 }
303 SelectionShape::Rectangular => {
304 if line < sel.start.line || line > sel.end.line {
305 return false;
306 }
307 let min_col = sel.start.col.min(sel.end.col);
308 let max_col = sel.start.col.max(sel.end.col);
309 col >= min_col && col <= max_col
310 }
311 }
312 }
313
314 #[must_use]
320 pub fn extract_text(&self, grid: &Grid, scrollback: &Scrollback) -> Option<String> {
321 let sel = self.current_selection()?;
322 let opts = CopyOptions::default();
323 Some(match self.shape {
324 SelectionShape::Linear => sel.extract_copy(grid, scrollback, &opts),
325 SelectionShape::Rectangular => sel.extract_rect(grid, scrollback, &opts),
326 })
327 }
328
329 #[must_use]
334 pub fn extract_copy(
335 &self,
336 grid: &Grid,
337 scrollback: &Scrollback,
338 opts: &CopyOptions,
339 ) -> Option<String> {
340 let sel = self.current_selection()?;
341 Some(match self.shape {
342 SelectionShape::Linear => sel.extract_copy(grid, scrollback, opts),
343 SelectionShape::Rectangular => sel.extract_rect(grid, scrollback, opts),
344 })
345 }
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum SelectionDirection {
355 Up,
356 Down,
357 Left,
358 Right,
359 Home,
361 End,
363 WordLeft,
365 WordRight,
367}
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum AutoScrollHint {
373 Up(u16),
375 Down(u16),
377 None,
379}
380
381#[derive(Debug, Clone, Copy)]
383pub struct GestureConfig {
384 pub multi_click_threshold_ms: u64,
386 pub multi_click_distance: u16,
388}
389
390impl Default for GestureConfig {
391 fn default() -> Self {
392 Self {
393 multi_click_threshold_ms: 400,
394 multi_click_distance: 2,
395 }
396 }
397}
398
399#[derive(Debug, Clone)]
413pub struct SelectionGestureController {
414 state: SelectionState,
415 last_click_time_ms: u64,
417 last_click_pos: Option<BufferPos>,
419 click_count: u8,
421 config: GestureConfig,
423}
424
425impl Default for SelectionGestureController {
426 fn default() -> Self {
427 Self::new()
428 }
429}
430
431impl SelectionGestureController {
432 #[must_use]
434 pub fn new() -> Self {
435 Self::with_config(GestureConfig::default())
436 }
437
438 #[must_use]
440 pub fn with_config(config: GestureConfig) -> Self {
441 Self {
442 state: SelectionState::new(),
443 last_click_time_ms: 0,
444 last_click_pos: None,
445 click_count: 0,
446 config,
447 }
448 }
449
450 #[must_use]
456 pub fn state(&self) -> &SelectionState {
457 &self.state
458 }
459
460 pub fn state_mut(&mut self) -> &mut SelectionState {
462 &mut self.state
463 }
464
465 #[must_use]
467 pub fn contains(&self, line: u32, col: u16) -> bool {
468 self.state.contains(line, col)
469 }
470
471 #[must_use]
473 pub fn phase(&self) -> SelectionPhase {
474 self.state.phase()
475 }
476
477 #[must_use]
479 pub fn has_selection(&self) -> bool {
480 self.state.has_selection()
481 }
482
483 #[must_use]
485 pub fn current_selection(&self) -> Option<Selection> {
486 self.state.current_selection()
487 }
488
489 #[must_use]
491 pub fn shape(&self) -> SelectionShape {
492 self.state.shape()
493 }
494
495 #[must_use]
510 pub fn viewport_to_buffer(
511 viewport_row: u16,
512 col: u16,
513 scrollback_len: usize,
514 _viewport_rows: u16,
515 scroll_offset_from_bottom: usize,
516 ) -> BufferPos {
517 let line = (scrollback_len as u64)
518 .saturating_sub(scroll_offset_from_bottom as u64)
519 .saturating_add(viewport_row as u64);
520 BufferPos::new(line.min(u32::MAX as u64) as u32, col)
521 }
522
523 #[allow(clippy::too_many_arguments)]
531 pub fn mouse_down(
532 &mut self,
533 viewport_row: u16,
534 col: u16,
535 time_ms: u64,
536 shift: bool,
537 alt: bool,
538 grid: &Grid,
539 scrollback: &Scrollback,
540 scroll_offset_from_bottom: usize,
541 ) -> BufferPos {
542 let pos = Self::viewport_to_buffer(
543 viewport_row,
544 col,
545 scrollback.len(),
546 grid.rows(),
547 scroll_offset_from_bottom,
548 );
549
550 let click_count = self.resolve_click_count(pos, time_ms);
552 self.click_count = click_count;
553 self.last_click_time_ms = time_ms;
554 self.last_click_pos = Some(pos);
555
556 if alt {
558 if self.state.shape() != SelectionShape::Rectangular {
559 self.state.toggle_shape();
560 }
561 } else if self.state.shape() != SelectionShape::Linear {
562 self.state.toggle_shape();
563 }
564
565 let granularity = match click_count {
566 1 => SelectionGranularity::Character,
567 2 => SelectionGranularity::Word,
568 _ => SelectionGranularity::Line,
569 };
570
571 if shift && self.state.has_selection() {
572 self.extend_to(pos, grid, scrollback);
574 } else {
575 match granularity {
577 SelectionGranularity::Character => {
578 self.state
579 .start_with_shape(pos, granularity, self.state.shape());
580 }
581 SelectionGranularity::Word => {
582 let word = Selection::word_at(pos, grid, scrollback);
583 let norm = word.normalized();
584 self.state
585 .start_with_shape(norm.start, granularity, self.state.shape());
586 self.state.drag(norm.end);
587 }
588 SelectionGranularity::Line => {
589 let line = Selection::line_at(pos.line, grid, scrollback);
590 let norm = line.normalized();
591 self.state
592 .start_with_shape(norm.start, granularity, self.state.shape());
593 self.state.drag(norm.end);
594 }
595 }
596 }
597
598 pos
599 }
600
601 pub fn mouse_drag(
605 &mut self,
606 viewport_row: i32,
607 col: u16,
608 grid: &Grid,
609 scrollback: &Scrollback,
610 viewport_rows: u16,
611 scroll_offset_from_bottom: usize,
612 ) -> AutoScrollHint {
613 if self.state.phase() != SelectionPhase::Selecting {
614 return AutoScrollHint::None;
615 }
616
617 let auto_scroll = if viewport_row < 0 {
619 AutoScrollHint::Up(viewport_row.unsigned_abs().min(u16::MAX as u32) as u16)
620 } else if viewport_row >= viewport_rows as i32 {
621 let overshoot = (viewport_row - viewport_rows as i32 + 1).min(u16::MAX as i32) as u16;
622 AutoScrollHint::Down(overshoot)
623 } else {
624 AutoScrollHint::None
625 };
626
627 let clamped_row = viewport_row.clamp(0, viewport_rows.saturating_sub(1) as i32) as u16;
629
630 let pos = Self::viewport_to_buffer(
631 clamped_row,
632 col,
633 scrollback.len(),
634 viewport_rows,
635 scroll_offset_from_bottom,
636 );
637
638 self.state.drag_expanded(pos, grid, scrollback);
639 auto_scroll
640 }
641
642 pub fn mouse_up(&mut self) {
644 self.state.commit();
645 }
646
647 pub fn cancel(&mut self) {
649 self.state.cancel();
650 self.click_count = 0;
651 }
652
653 pub fn keyboard_select(
664 &mut self,
665 direction: SelectionDirection,
666 cursor_pos: BufferPos,
667 cols: u16,
668 total_lines: u32,
669 ) {
670 if cols == 0 || total_lines == 0 {
671 return;
672 }
673
674 let max_col = cols.saturating_sub(1);
675 let max_line = total_lines.saturating_sub(1);
676
677 if !self.state.has_selection() {
679 self.state
680 .start(cursor_pos, SelectionGranularity::Character);
681 self.state.commit();
682 }
683
684 let sel = match self.state.current_selection() {
685 Some(s) => s,
686 None => return,
687 };
688
689 let raw = self.state.raw_selection().unwrap_or(sel);
692 let endpoint = raw.end;
693
694 let new_endpoint = match direction {
695 SelectionDirection::Left => {
696 if endpoint.col > 0 {
697 BufferPos::new(endpoint.line, endpoint.col - 1)
698 } else if endpoint.line > 0 {
699 BufferPos::new(endpoint.line - 1, max_col)
701 } else {
702 endpoint
703 }
704 }
705 SelectionDirection::Right => {
706 if endpoint.col < max_col {
707 BufferPos::new(endpoint.line, endpoint.col + 1)
708 } else if endpoint.line < max_line {
709 BufferPos::new(endpoint.line + 1, 0)
711 } else {
712 endpoint
713 }
714 }
715 SelectionDirection::Up => {
716 if endpoint.line > 0 {
717 BufferPos::new(endpoint.line - 1, endpoint.col)
718 } else {
719 endpoint
720 }
721 }
722 SelectionDirection::Down => {
723 if endpoint.line < max_line {
724 BufferPos::new(endpoint.line + 1, endpoint.col)
725 } else {
726 endpoint
727 }
728 }
729 SelectionDirection::Home => BufferPos::new(endpoint.line, 0),
730 SelectionDirection::End => BufferPos::new(endpoint.line, max_col),
731 SelectionDirection::WordLeft => {
732 self.find_word_boundary_left(endpoint, cols, max_line)
734 }
735 SelectionDirection::WordRight => {
736 self.find_word_boundary_right(endpoint, cols, max_line)
738 }
739 };
740
741 let anchor = raw.start;
743 self.state.start(anchor, SelectionGranularity::Character);
744 self.state.drag(new_endpoint);
745 self.state.commit();
746 }
747
748 pub fn select_all(&mut self, total_lines: u32, cols: u16) {
750 if total_lines == 0 || cols == 0 {
751 return;
752 }
753 let start = BufferPos::new(0, 0);
754 let end = BufferPos::new(total_lines.saturating_sub(1), cols.saturating_sub(1));
755 self.state.start(start, SelectionGranularity::Character);
756 self.state.drag(end);
757 self.state.commit();
758 }
759
760 #[must_use]
766 pub fn extract_text(&self, grid: &Grid, scrollback: &Scrollback) -> Option<String> {
767 self.state.extract_text(grid, scrollback)
768 }
769
770 #[must_use]
772 pub fn extract_copy(
773 &self,
774 grid: &Grid,
775 scrollback: &Scrollback,
776 opts: &CopyOptions,
777 ) -> Option<String> {
778 self.state.extract_copy(grid, scrollback, opts)
779 }
780
781 fn resolve_click_count(&self, pos: BufferPos, time_ms: u64) -> u8 {
787 if let Some(last_pos) = self.last_click_pos {
788 let dt = time_ms.saturating_sub(self.last_click_time_ms);
789 let d_line = (pos.line as i64 - last_pos.line as i64).unsigned_abs();
790 let d_col = (pos.col as i64 - last_pos.col as i64).unsigned_abs();
791 let distance = d_line + d_col;
792
793 if dt <= self.config.multi_click_threshold_ms
794 && distance <= self.config.multi_click_distance as u64
795 {
796 return if self.click_count >= 3 {
798 1
799 } else {
800 self.click_count + 1
801 };
802 }
803 }
804 1
805 }
806
807 fn extend_to(&mut self, pos: BufferPos, grid: &Grid, scrollback: &Scrollback) {
809 if let Some(anchor) = self.state.anchor() {
812 let shape = self.state.shape();
813 let granularity = self.state.granularity();
814 self.state.start_with_shape(anchor, granularity, shape);
815 self.state.drag_expanded(pos, grid, scrollback);
816 }
817 }
818
819 fn find_word_boundary_left(&self, pos: BufferPos, _cols: u16, _max_line: u32) -> BufferPos {
821 if pos.col > 0 {
823 let jump = pos.col.min(4);
826 BufferPos::new(pos.line, pos.col - jump)
827 } else if pos.line > 0 {
828 BufferPos::new(pos.line - 1, 0)
829 } else {
830 pos
831 }
832 }
833
834 fn find_word_boundary_right(&self, pos: BufferPos, cols: u16, max_line: u32) -> BufferPos {
836 let max_col = cols.saturating_sub(1);
837 if pos.col < max_col {
838 let jump = (max_col - pos.col).min(4);
839 BufferPos::new(pos.line, pos.col + jump)
840 } else if pos.line < max_line {
841 BufferPos::new(pos.line + 1, max_col.min(3))
842 } else {
843 pos
844 }
845 }
846}
847
848#[cfg(test)]
853mod tests {
854 use super::*;
855
856 fn pos(line: u32, col: u16) -> BufferPos {
857 BufferPos::new(line, col)
858 }
859
860 #[test]
865 fn initial_state_is_none() {
866 let state = SelectionState::new();
867 assert_eq!(state.phase(), SelectionPhase::None);
868 assert!(state.current_selection().is_none());
869 assert!(state.anchor().is_none());
870 assert!(!state.has_selection());
871 }
872
873 #[test]
874 fn start_transitions_to_selecting() {
875 let mut state = SelectionState::new();
876 state.start(pos(5, 10), SelectionGranularity::Character);
877 assert_eq!(state.phase(), SelectionPhase::Selecting);
878 assert_eq!(state.anchor(), Some(pos(5, 10)));
879 assert!(state.has_selection());
880 }
881
882 #[test]
883 fn commit_transitions_to_active() {
884 let mut state = SelectionState::new();
885 state.start(pos(0, 0), SelectionGranularity::Character);
886 state.drag(pos(2, 5));
887 state.commit();
888 assert_eq!(state.phase(), SelectionPhase::Active);
889 assert!(state.has_selection());
890 }
891
892 #[test]
893 fn cancel_clears_selection() {
894 let mut state = SelectionState::new();
895 state.start(pos(0, 0), SelectionGranularity::Character);
896 state.drag(pos(3, 10));
897 state.cancel();
898 assert_eq!(state.phase(), SelectionPhase::None);
899 assert!(state.current_selection().is_none());
900 assert!(state.anchor().is_none());
901 }
902
903 #[test]
904 fn cancel_from_active() {
905 let mut state = SelectionState::new();
906 state.start(pos(0, 0), SelectionGranularity::Character);
907 state.commit();
908 state.cancel();
909 assert_eq!(state.phase(), SelectionPhase::None);
910 }
911
912 #[test]
913 fn start_from_active_restarts() {
914 let mut state = SelectionState::new();
915 state.start(pos(0, 0), SelectionGranularity::Character);
916 state.drag(pos(2, 5));
917 state.commit();
918 state.start(pos(10, 3), SelectionGranularity::Word);
920 assert_eq!(state.phase(), SelectionPhase::Selecting);
921 assert_eq!(state.anchor(), Some(pos(10, 3)));
922 assert_eq!(state.granularity(), SelectionGranularity::Word);
923 }
924
925 #[test]
926 fn commit_when_not_selecting_is_noop() {
927 let mut state = SelectionState::new();
928 state.commit(); assert_eq!(state.phase(), SelectionPhase::None);
930 }
931
932 #[test]
933 fn drag_when_not_selecting_is_noop() {
934 let mut state = SelectionState::new();
935 state.drag(pos(5, 5));
936 assert_eq!(state.phase(), SelectionPhase::None);
937 assert!(state.current_selection().is_none());
938 }
939
940 #[test]
945 fn selection_always_normalized() {
946 let mut state = SelectionState::new();
947 state.start(pos(5, 10), SelectionGranularity::Character);
949 state.drag(pos(2, 3));
950
951 let sel = state.current_selection().unwrap();
952 assert!(
953 (sel.start.line, sel.start.col) <= (sel.end.line, sel.end.col),
954 "normalized invariant violated: {sel:?}"
955 );
956 assert_eq!(sel.start, pos(2, 3));
957 assert_eq!(sel.end, pos(5, 10));
958 }
959
960 #[test]
961 fn raw_selection_preserves_order() {
962 let mut state = SelectionState::new();
963 state.start(pos(5, 10), SelectionGranularity::Character);
964 state.drag(pos(2, 3));
965
966 let raw = state.raw_selection().unwrap();
967 assert_eq!(raw.start, pos(5, 10));
969 assert_eq!(raw.end, pos(2, 3));
970 }
971
972 #[test]
977 fn contains_single_line() {
978 let mut state = SelectionState::new();
979 state.start(pos(3, 5), SelectionGranularity::Character);
980 state.drag(pos(3, 15));
981
982 assert!(state.contains(3, 5));
983 assert!(state.contains(3, 10));
984 assert!(state.contains(3, 15));
985 assert!(!state.contains(3, 4));
986 assert!(!state.contains(3, 16));
987 assert!(!state.contains(2, 10));
988 assert!(!state.contains(4, 10));
989 }
990
991 #[test]
992 fn contains_multiline_linear() {
993 let mut state = SelectionState::new();
994 state.start(pos(2, 10), SelectionGranularity::Character);
995 state.drag(pos(5, 20));
996 state.commit();
997
998 assert!(!state.contains(2, 9));
1000 assert!(state.contains(2, 10));
1001 assert!(state.contains(2, 50));
1002 assert!(state.contains(3, 0));
1004 assert!(state.contains(4, 100));
1005 assert!(state.contains(5, 0));
1007 assert!(state.contains(5, 20));
1008 assert!(!state.contains(5, 21));
1009 assert!(!state.contains(1, 10));
1011 assert!(!state.contains(6, 0));
1012 }
1013
1014 #[test]
1019 fn contains_rectangular() {
1020 let mut state = SelectionState::new();
1021 state.start_with_shape(
1022 pos(2, 5),
1023 SelectionGranularity::Character,
1024 SelectionShape::Rectangular,
1025 );
1026 state.drag(pos(5, 15));
1027
1028 assert!(state.contains(2, 5));
1030 assert!(state.contains(3, 10));
1031 assert!(state.contains(5, 15));
1032 assert!(!state.contains(3, 4));
1034 assert!(!state.contains(3, 16));
1035 assert!(!state.contains(1, 10));
1037 assert!(!state.contains(6, 10));
1038 }
1039
1040 #[test]
1045 fn toggle_shape() {
1046 let mut state = SelectionState::new();
1047 assert_eq!(state.shape(), SelectionShape::Linear);
1048 state.toggle_shape();
1049 assert_eq!(state.shape(), SelectionShape::Rectangular);
1050 state.toggle_shape();
1051 assert_eq!(state.shape(), SelectionShape::Linear);
1052 }
1053
1054 #[test]
1059 fn default_matches_new() {
1060 let from_new = SelectionState::new();
1061 let from_default = SelectionState::default();
1062 assert_eq!(from_new.phase(), from_default.phase());
1063 assert_eq!(
1064 from_new.current_selection(),
1065 from_default.current_selection()
1066 );
1067 }
1068
1069 #[test]
1074 fn deterministic_transitions() {
1075 let run = || {
1077 let mut s = SelectionState::new();
1078 s.start(pos(1, 5), SelectionGranularity::Character);
1079 s.drag(pos(3, 10));
1080 s.commit();
1081 s.current_selection()
1082 };
1083 assert_eq!(run(), run());
1084 }
1085
1086 fn grid_from_lines(cols: u16, lines: &[&str]) -> crate::grid::Grid {
1091 let rows = lines.len() as u16;
1092 let mut g = crate::grid::Grid::new(cols, rows);
1093 for (r, text) in lines.iter().enumerate() {
1094 for (c, ch) in text.chars().enumerate() {
1095 if c >= cols as usize {
1096 break;
1097 }
1098 g.cell_mut(r as u16, c as u16).unwrap().set_content(ch, 1);
1099 }
1100 }
1101 g
1102 }
1103
1104 #[test]
1105 fn extract_text_linear_basic() {
1106 let sb = crate::scrollback::Scrollback::new(0);
1107 let grid = grid_from_lines(10, &["abcdef", "ghijkl"]);
1108 let mut state = SelectionState::new();
1109 state.start(pos(0, 1), SelectionGranularity::Character);
1110 state.drag(pos(1, 3));
1111 state.commit();
1112
1113 let text = state.extract_text(&grid, &sb).unwrap();
1114 assert_eq!(text, "bcdef\nghij");
1115 }
1116
1117 #[test]
1118 fn extract_text_rectangular() {
1119 let sb = crate::scrollback::Scrollback::new(0);
1120 let grid = grid_from_lines(10, &["abcdef", "ghijkl", "mnopqr"]);
1121 let mut state = SelectionState::new();
1122 state.start_with_shape(
1123 pos(0, 2),
1124 SelectionGranularity::Character,
1125 SelectionShape::Rectangular,
1126 );
1127 state.drag(pos(2, 4));
1128 state.commit();
1129
1130 let text = state.extract_text(&grid, &sb).unwrap();
1131 assert_eq!(text, "cde\nijk\nopr");
1132 }
1133
1134 #[test]
1135 fn extract_copy_with_options() {
1136 let sb = crate::scrollback::Scrollback::new(0);
1137 let mut grid = crate::grid::Grid::new(10, 1);
1138 grid.cell_mut(0, 0).unwrap().set_content('e', 1);
1139 grid.cell_mut(0, 0).unwrap().push_combining('\u{0301}');
1140 grid.cell_mut(0, 1).unwrap().set_content('x', 1);
1141
1142 let mut state = SelectionState::new();
1143 state.start(pos(0, 0), SelectionGranularity::Character);
1144 state.drag(pos(0, 1));
1145 state.commit();
1146
1147 let opts = CopyOptions::default();
1149 let text = state.extract_copy(&grid, &sb, &opts).unwrap();
1150 assert_eq!(text, "e\u{0301}x");
1151
1152 let opts = CopyOptions {
1154 include_combining: false,
1155 ..Default::default()
1156 };
1157 let text = state.extract_copy(&grid, &sb, &opts).unwrap();
1158 assert_eq!(text, "ex");
1159 }
1160
1161 #[test]
1162 fn extract_copy_no_selection_returns_none() {
1163 let sb = crate::scrollback::Scrollback::new(0);
1164 let grid = grid_from_lines(10, &["test"]);
1165 let state = SelectionState::new();
1166 assert!(
1167 state
1168 .extract_copy(&grid, &sb, &CopyOptions::default())
1169 .is_none()
1170 );
1171 }
1172
1173 #[test]
1174 fn extract_text_rect_with_combining() {
1175 let sb = crate::scrollback::Scrollback::new(0);
1176 let mut grid = crate::grid::Grid::new(10, 2);
1177 grid.cell_mut(0, 0).unwrap().set_content('e', 1);
1178 grid.cell_mut(0, 0).unwrap().push_combining('\u{0301}');
1179 grid.cell_mut(0, 1).unwrap().set_content('x', 1);
1180 grid.cell_mut(1, 0).unwrap().set_content('a', 1);
1181 grid.cell_mut(1, 1).unwrap().set_content('b', 1);
1182
1183 let mut state = SelectionState::new();
1184 state.start_with_shape(
1185 pos(0, 0),
1186 SelectionGranularity::Character,
1187 SelectionShape::Rectangular,
1188 );
1189 state.drag(pos(1, 1));
1190 state.commit();
1191
1192 let text = state.extract_text(&grid, &sb).unwrap();
1193 assert_eq!(text, "e\u{0301}x\nab");
1194 }
1195
1196 #[test]
1203 fn viewport_to_buffer_no_scrollback_no_offset() {
1204 let p = SelectionGestureController::viewport_to_buffer(0, 5, 0, 24, 0);
1206 assert_eq!(p.line, 0);
1207 assert_eq!(p.col, 5);
1208 }
1209
1210 #[test]
1211 fn viewport_to_buffer_with_scrollback_no_offset() {
1212 let p = SelectionGestureController::viewport_to_buffer(0, 0, 100, 24, 0);
1214 assert_eq!(p.line, 100);
1215 }
1216
1217 #[test]
1218 fn viewport_to_buffer_with_scrollback_and_offset() {
1219 let p = SelectionGestureController::viewport_to_buffer(0, 0, 100, 24, 50);
1221 assert_eq!(p.line, 50);
1222 }
1223
1224 #[test]
1225 fn viewport_to_buffer_row_offset() {
1226 let p = SelectionGestureController::viewport_to_buffer(5, 3, 100, 24, 0);
1228 assert_eq!(p.line, 105);
1229 assert_eq!(p.col, 3);
1230 }
1231
1232 #[test]
1235 fn single_click_granularity() {
1236 let mut gc = SelectionGestureController::new();
1237 let sb = crate::scrollback::Scrollback::new(0);
1238 let grid = grid_from_lines(10, &["hello"]);
1239
1240 gc.mouse_down(0, 2, 1000, false, false, &grid, &sb, 0);
1241 assert_eq!(gc.state().granularity(), SelectionGranularity::Character);
1242 }
1243
1244 #[test]
1245 fn double_click_selects_word() {
1246 let mut gc = SelectionGestureController::new();
1247 let sb = crate::scrollback::Scrollback::new(0);
1248 let grid = grid_from_lines(20, &["hello world"]);
1249
1250 gc.mouse_down(0, 2, 1000, false, false, &grid, &sb, 0);
1252 gc.mouse_up();
1253 gc.mouse_down(0, 2, 1200, false, false, &grid, &sb, 0);
1255
1256 assert_eq!(gc.state().granularity(), SelectionGranularity::Word);
1257 let text = gc.extract_text(&grid, &sb).unwrap();
1258 assert_eq!(text, "hello");
1259 }
1260
1261 #[test]
1262 fn triple_click_selects_line() {
1263 let mut gc = SelectionGestureController::new();
1264 let sb = crate::scrollback::Scrollback::new(0);
1265 let grid = grid_from_lines(20, &["hello world"]);
1266
1267 gc.mouse_down(0, 2, 1000, false, false, &grid, &sb, 0);
1268 gc.mouse_up();
1269 gc.mouse_down(0, 2, 1200, false, false, &grid, &sb, 0);
1270 gc.mouse_up();
1271 gc.mouse_down(0, 2, 1400, false, false, &grid, &sb, 0);
1272
1273 assert_eq!(gc.state().granularity(), SelectionGranularity::Line);
1274 }
1275
1276 #[test]
1277 fn click_count_resets_after_delay() {
1278 let mut gc = SelectionGestureController::new();
1279 let sb = crate::scrollback::Scrollback::new(0);
1280 let grid = grid_from_lines(10, &["test"]);
1281
1282 gc.mouse_down(0, 0, 1000, false, false, &grid, &sb, 0);
1283 gc.mouse_up();
1284 gc.mouse_down(0, 0, 2000, false, false, &grid, &sb, 0);
1286 assert_eq!(gc.state().granularity(), SelectionGranularity::Character);
1287 }
1288
1289 #[test]
1290 fn click_count_resets_after_distance() {
1291 let mut gc = SelectionGestureController::new();
1292 let sb = crate::scrollback::Scrollback::new(0);
1293 let grid = grid_from_lines(20, &["test"]);
1294
1295 gc.mouse_down(0, 0, 1000, false, false, &grid, &sb, 0);
1296 gc.mouse_up();
1297 gc.mouse_down(0, 10, 1200, false, false, &grid, &sb, 0);
1299 assert_eq!(gc.state().granularity(), SelectionGranularity::Character);
1300 }
1301
1302 #[test]
1305 fn drag_creates_selection() {
1306 let mut gc = SelectionGestureController::new();
1307 let sb = crate::scrollback::Scrollback::new(0);
1308 let grid = grid_from_lines(20, &["abcdefghij"]);
1309
1310 gc.mouse_down(0, 2, 0, false, false, &grid, &sb, 0);
1311 gc.mouse_drag(0, 6, &grid, &sb, 1, 0);
1312 gc.mouse_up();
1313
1314 let text = gc.extract_text(&grid, &sb).unwrap();
1315 assert_eq!(text, "cdefg");
1316 }
1317
1318 #[test]
1319 fn drag_past_viewport_returns_auto_scroll_up() {
1320 let mut gc = SelectionGestureController::new();
1321 let sb = crate::scrollback::Scrollback::new(0);
1322 let grid = grid_from_lines(20, &["a", "b", "c", "d"]);
1323
1324 gc.mouse_down(1, 0, 0, false, false, &grid, &sb, 0);
1325 let hint = gc.mouse_drag(-2, 0, &grid, &sb, 4, 0);
1326 assert_eq!(hint, AutoScrollHint::Up(2));
1327 }
1328
1329 #[test]
1330 fn drag_past_viewport_returns_auto_scroll_down() {
1331 let mut gc = SelectionGestureController::new();
1332 let sb = crate::scrollback::Scrollback::new(0);
1333 let grid = grid_from_lines(20, &["a", "b", "c", "d"]);
1334
1335 gc.mouse_down(1, 0, 0, false, false, &grid, &sb, 0);
1336 let hint = gc.mouse_drag(6, 0, &grid, &sb, 4, 0);
1337 assert_eq!(hint, AutoScrollHint::Down(3));
1338 }
1339
1340 #[test]
1341 fn drag_within_viewport_returns_no_scroll() {
1342 let mut gc = SelectionGestureController::new();
1343 let sb = crate::scrollback::Scrollback::new(0);
1344 let grid = grid_from_lines(20, &["a", "b", "c", "d"]);
1345
1346 gc.mouse_down(1, 0, 0, false, false, &grid, &sb, 0);
1347 let hint = gc.mouse_drag(2, 0, &grid, &sb, 4, 0);
1348 assert_eq!(hint, AutoScrollHint::None);
1349 }
1350
1351 #[test]
1354 fn shift_click_extends_selection() {
1355 let mut gc = SelectionGestureController::new();
1356 let sb = crate::scrollback::Scrollback::new(0);
1357 let grid = grid_from_lines(20, &["abcdefghij"]);
1358
1359 gc.mouse_down(0, 2, 0, false, false, &grid, &sb, 0);
1361 gc.mouse_up();
1362 gc.mouse_down(0, 7, 500, true, false, &grid, &sb, 0);
1364 gc.mouse_up();
1365
1366 assert!(gc.has_selection());
1367 let sel = gc.current_selection().unwrap();
1368 assert_eq!(sel.start.col, 2);
1369 assert_eq!(sel.end.col, 7);
1370 }
1371
1372 #[test]
1375 fn alt_click_starts_rectangular_selection() {
1376 let mut gc = SelectionGestureController::new();
1377 let sb = crate::scrollback::Scrollback::new(0);
1378 let grid = grid_from_lines(20, &["abcdefghij", "klmnopqrst"]);
1379
1380 gc.mouse_down(0, 2, 0, false, true, &grid, &sb, 0);
1381 gc.mouse_drag(1, 5, &grid, &sb, 2, 0);
1382 gc.mouse_up();
1383
1384 assert_eq!(gc.shape(), SelectionShape::Rectangular);
1385 assert!(gc.has_selection());
1386 }
1387
1388 #[test]
1391 fn keyboard_select_right() {
1392 let mut gc = SelectionGestureController::new();
1393 gc.keyboard_select(SelectionDirection::Right, pos(0, 5), 20, 10);
1394
1395 let sel = gc.current_selection().unwrap();
1396 assert_eq!(sel.start, pos(0, 5));
1397 assert_eq!(sel.end, pos(0, 6));
1398 }
1399
1400 #[test]
1401 fn keyboard_select_left() {
1402 let mut gc = SelectionGestureController::new();
1403 gc.keyboard_select(SelectionDirection::Right, pos(0, 5), 20, 10);
1404 gc.keyboard_select(SelectionDirection::Right, pos(0, 5), 20, 10);
1405 gc.keyboard_select(SelectionDirection::Left, pos(0, 5), 20, 10);
1406
1407 let sel = gc.current_selection().unwrap();
1408 assert_eq!(sel.start, pos(0, 5));
1409 assert_eq!(sel.end, pos(0, 6));
1410 }
1411
1412 #[test]
1413 fn keyboard_select_down_preserves_column() {
1414 let mut gc = SelectionGestureController::new();
1415 gc.keyboard_select(SelectionDirection::Down, pos(0, 5), 20, 10);
1416
1417 let sel = gc.current_selection().unwrap();
1418 assert_eq!(sel.start, pos(0, 5));
1419 assert_eq!(sel.end, pos(1, 5));
1420 }
1421
1422 #[test]
1423 fn keyboard_select_home() {
1424 let mut gc = SelectionGestureController::new();
1425 gc.keyboard_select(SelectionDirection::Home, pos(2, 10), 20, 10);
1426
1427 let sel = gc.current_selection().unwrap();
1428 assert_eq!(sel.start.col, 0);
1429 assert_eq!(sel.end.col, 10);
1430 }
1431
1432 #[test]
1433 fn keyboard_select_end() {
1434 let mut gc = SelectionGestureController::new();
1435 gc.keyboard_select(SelectionDirection::End, pos(2, 5), 20, 10);
1436
1437 let sel = gc.current_selection().unwrap();
1438 assert_eq!(sel.start, pos(2, 5));
1439 assert_eq!(sel.end, pos(2, 19));
1440 }
1441
1442 #[test]
1443 fn keyboard_select_right_wraps_to_next_line() {
1444 let mut gc = SelectionGestureController::new();
1445 gc.keyboard_select(SelectionDirection::End, pos(0, 0), 10, 5);
1446 gc.keyboard_select(SelectionDirection::Right, pos(0, 0), 10, 5);
1447
1448 let sel = gc.current_selection().unwrap();
1449 assert_eq!(sel.end, pos(1, 0));
1450 }
1451
1452 #[test]
1453 fn keyboard_select_left_wraps_to_prev_line() {
1454 let mut gc = SelectionGestureController::new();
1455 gc.keyboard_select(SelectionDirection::Left, pos(1, 0), 10, 5);
1458
1459 let sel = gc.current_selection().unwrap();
1460 assert_eq!(sel.start, pos(0, 9));
1461 assert_eq!(sel.end, pos(1, 0));
1462 }
1463
1464 #[test]
1467 fn select_all_covers_entire_buffer() {
1468 let mut gc = SelectionGestureController::new();
1469 gc.select_all(100, 80);
1470
1471 let sel = gc.current_selection().unwrap();
1472 assert_eq!(sel.start, pos(0, 0));
1473 assert_eq!(sel.end, pos(99, 79));
1474 }
1475
1476 #[test]
1477 fn select_all_empty_buffer_is_noop() {
1478 let mut gc = SelectionGestureController::new();
1479 gc.select_all(0, 80);
1480 assert!(!gc.has_selection());
1481 }
1482
1483 #[test]
1486 fn cancel_clears_gesture_state() {
1487 let mut gc = SelectionGestureController::new();
1488 let sb = crate::scrollback::Scrollback::new(0);
1489 let grid = grid_from_lines(10, &["test"]);
1490
1491 gc.mouse_down(0, 0, 0, false, false, &grid, &sb, 0);
1492 gc.mouse_up();
1493 assert!(gc.has_selection());
1494
1495 gc.cancel();
1496 assert!(!gc.has_selection());
1497 assert_eq!(gc.phase(), SelectionPhase::None);
1498 }
1499
1500 #[test]
1503 fn gesture_controller_deterministic() {
1504 let run = || {
1505 let mut gc = SelectionGestureController::new();
1506 let sb = crate::scrollback::Scrollback::new(0);
1507 let grid = grid_from_lines(20, &["hello world", "foo bar baz"]);
1508
1509 gc.mouse_down(0, 3, 100, false, false, &grid, &sb, 0);
1510 gc.mouse_drag(1, 6, &grid, &sb, 2, 0);
1511 gc.mouse_up();
1512 gc.current_selection()
1513 };
1514 assert_eq!(run(), run());
1515 }
1516
1517 #[test]
1520 fn gesture_controller_default() {
1521 let gc = SelectionGestureController::default();
1522 assert!(!gc.has_selection());
1523 assert_eq!(gc.phase(), SelectionPhase::None);
1524 }
1525
1526 #[test]
1529 fn custom_config_applied() {
1530 let config = GestureConfig {
1531 multi_click_threshold_ms: 100,
1532 multi_click_distance: 1,
1533 };
1534 let gc = SelectionGestureController::with_config(config);
1535 assert_eq!(gc.config.multi_click_threshold_ms, 100);
1536 }
1537
1538 #[test]
1541 fn quadruple_click_wraps_to_single() {
1542 let mut gc = SelectionGestureController::new();
1543 let sb = crate::scrollback::Scrollback::new(0);
1544 let grid = grid_from_lines(20, &["hello world"]);
1545
1546 gc.mouse_down(0, 0, 100, false, false, &grid, &sb, 0);
1547 gc.mouse_up();
1548 gc.mouse_down(0, 0, 200, false, false, &grid, &sb, 0);
1549 gc.mouse_up();
1550 gc.mouse_down(0, 0, 300, false, false, &grid, &sb, 0);
1551 gc.mouse_up();
1552 gc.mouse_down(0, 0, 400, false, false, &grid, &sb, 0);
1554 assert_eq!(gc.state().granularity(), SelectionGranularity::Character);
1555 }
1556}