1#![forbid(unsafe_code)]
2use crate::cell::Cell;
13use crate::grid::Grid;
14use crate::scrollback::Scrollback;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub struct BufferPos {
22 pub line: u32,
23 pub col: u16,
24}
25
26impl BufferPos {
27 #[must_use]
28 pub const fn new(line: u32, col: u16) -> Self {
29 Self { line, col }
30 }
31
32 #[must_use]
34 pub fn from_viewport(scrollback_lines: usize, row: u16, col: u16) -> Self {
35 Self {
36 line: scrollback_lines as u32 + row as u32,
37 col,
38 }
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct Selection {
47 pub start: BufferPos,
48 pub end: BufferPos,
49}
50
51impl Selection {
52 #[must_use]
53 pub const fn new(start: BufferPos, end: BufferPos) -> Self {
54 Self { start, end }
55 }
56
57 #[must_use]
59 pub fn normalized(self) -> Self {
60 if (self.start.line, self.start.col) <= (self.end.line, self.end.col) {
61 self
62 } else {
63 Self {
64 start: self.end,
65 end: self.start,
66 }
67 }
68 }
69
70 #[must_use]
72 pub fn char_at(pos: BufferPos, grid: &Grid, scrollback: &Scrollback) -> Self {
73 let cols = grid.cols();
74 if cols == 0 {
75 return Self::new(pos, pos);
76 }
77
78 let line = pos.line;
79 let col = pos.col.min(cols.saturating_sub(1));
80 let lead_col = normalize_to_wide_lead(line, col, grid, scrollback);
81 let end_col = wide_end_col(line, lead_col, grid, scrollback, cols);
82 Self::new(
83 BufferPos::new(line, lead_col),
84 BufferPos::new(line, end_col),
85 )
86 }
87
88 #[must_use]
90 pub fn line_at(line: u32, grid: &Grid, scrollback: &Scrollback) -> Self {
91 let cols = grid.cols();
92 if cols == 0 || total_lines(grid, scrollback) == 0 {
93 let p = BufferPos::new(line, 0);
94 return Self::new(p, p);
95 }
96 let max_line = total_lines(grid, scrollback).saturating_sub(1);
97 let line = line.min(max_line);
98 Self::new(
99 BufferPos::new(line, 0),
100 BufferPos::new(line, cols.saturating_sub(1)),
101 )
102 }
103
104 #[must_use]
109 pub fn word_at(pos: BufferPos, grid: &Grid, scrollback: &Scrollback) -> Self {
110 let cols = grid.cols();
111 if cols == 0 || total_lines(grid, scrollback) == 0 {
112 return Self::new(pos, pos);
113 }
114
115 let max_line = total_lines(grid, scrollback).saturating_sub(1);
116 let line = pos.line.min(max_line);
117 let col = pos.col.min(cols.saturating_sub(1));
118 let col = normalize_to_wide_lead(line, col, grid, scrollback);
119
120 let ch = cell_char(line, col, grid, scrollback).unwrap_or(' ');
121 let target_class = classify_char(ch);
122
123 let mut start_col = col;
125 let mut end_col = wide_end_col(line, col, grid, scrollback, cols);
126
127 while start_col > 0 {
129 let probe = start_col - 1;
130 let probe = normalize_to_wide_lead(line, probe, grid, scrollback);
131 let ch = cell_char(line, probe, grid, scrollback).unwrap_or(' ');
132 if classify_char(ch) != target_class {
133 break;
134 }
135 start_col = probe;
136 }
137
138 loop {
140 let next = end_col.saturating_add(1);
141 if next >= cols {
142 break;
143 }
144 let next = normalize_to_wide_lead(line, next, grid, scrollback);
145 let ch = cell_char(line, next, grid, scrollback).unwrap_or(' ');
146 if classify_char(ch) != target_class {
147 break;
148 }
149 end_col = wide_end_col(line, next, grid, scrollback, cols);
150 if end_col >= cols.saturating_sub(1) {
151 break;
152 }
153 }
154
155 Self::new(
156 BufferPos::new(line, start_col),
157 BufferPos::new(line, end_col),
158 )
159 }
160
161 #[must_use]
168 pub fn extract_text(&self, grid: &Grid, scrollback: &Scrollback) -> String {
169 let cols = grid.cols();
170 if cols == 0 {
171 return String::new();
172 }
173
174 let total = total_lines(grid, scrollback);
175 if total == 0 {
176 return String::new();
177 }
178
179 let sel = self.normalized();
180 let start_line = sel.start.line.min(total.saturating_sub(1));
181 let end_line = sel.end.line.min(total.saturating_sub(1));
182
183 let mut out = String::new();
184
185 for line in start_line..=end_line {
186 let sc = if line == start_line {
187 sel.start.col.min(cols.saturating_sub(1))
188 } else {
189 0
190 };
191 let ec = if line == end_line {
192 sel.end.col.min(cols.saturating_sub(1))
193 } else {
194 cols.saturating_sub(1)
195 };
196
197 let mut line_buf = String::new();
198 if sc <= ec {
199 for col in sc..=ec {
200 if let Some(cell) = cell_at(line, col, grid, scrollback) {
201 if cell.is_wide_continuation() {
202 continue;
203 }
204 line_buf.push(cell.content());
205 } else {
206 line_buf.push(' ');
207 }
208 }
209 }
210 trim_trailing_spaces(&mut line_buf);
211 out.push_str(&line_buf);
212
213 if line != end_line && should_insert_newline(line + 1, scrollback) {
214 out.push('\n');
215 }
216 }
217
218 out
219 }
220}
221
222#[derive(Debug, Clone)]
228pub struct CopyOptions {
229 pub include_combining: bool,
235 pub trim_trailing: bool,
237 pub join_soft_wraps: bool,
243}
244
245impl Default for CopyOptions {
246 fn default() -> Self {
247 Self {
248 include_combining: true,
249 trim_trailing: true,
250 join_soft_wraps: true,
251 }
252 }
253}
254
255impl Selection {
256 #[must_use]
264 pub fn extract_copy(&self, grid: &Grid, scrollback: &Scrollback, opts: &CopyOptions) -> String {
265 let cols = grid.cols();
266 if cols == 0 {
267 return String::new();
268 }
269
270 let total = total_lines(grid, scrollback);
271 if total == 0 {
272 return String::new();
273 }
274
275 let sel = self.normalized();
276 let start_line = sel.start.line.min(total.saturating_sub(1));
277 let end_line = sel.end.line.min(total.saturating_sub(1));
278
279 let mut out = String::new();
280
281 for line in start_line..=end_line {
282 let sc = if line == start_line {
283 sel.start.col.min(cols.saturating_sub(1))
284 } else {
285 0
286 };
287 let ec = if line == end_line {
288 sel.end.col.min(cols.saturating_sub(1))
289 } else {
290 cols.saturating_sub(1)
291 };
292
293 let mut line_buf = String::new();
294 if sc <= ec {
295 for col in sc..=ec {
296 if let Some(cell) = cell_at(line, col, grid, scrollback) {
297 if cell.is_wide_continuation() {
298 continue;
299 }
300 line_buf.push(cell.content());
301 if opts.include_combining {
302 for &mark in cell.combining_marks() {
303 line_buf.push(mark);
304 }
305 }
306 } else {
307 line_buf.push(' ');
308 }
309 }
310 }
311 if opts.trim_trailing {
312 trim_trailing_spaces(&mut line_buf);
313 }
314 out.push_str(&line_buf);
315
316 if line != end_line {
317 let insert_nl = if opts.join_soft_wraps {
318 should_insert_newline(line + 1, scrollback)
319 } else {
320 true
321 };
322 if insert_nl {
323 out.push('\n');
324 }
325 }
326 }
327
328 out
329 }
330
331 #[must_use]
338 pub fn extract_rect(&self, grid: &Grid, scrollback: &Scrollback, opts: &CopyOptions) -> String {
339 let cols = grid.cols();
340 if cols == 0 {
341 return String::new();
342 }
343
344 let total = total_lines(grid, scrollback);
345 if total == 0 {
346 return String::new();
347 }
348
349 let sel = self.normalized();
350 let start_line = sel.start.line.min(total.saturating_sub(1));
351 let end_line = sel.end.line.min(total.saturating_sub(1));
352 let min_col = sel.start.col.min(sel.end.col).min(cols.saturating_sub(1));
353 let max_col = sel.start.col.max(sel.end.col).min(cols.saturating_sub(1));
354
355 let mut out = String::new();
356
357 for line in start_line..=end_line {
358 let mut line_buf = String::new();
359 for col in min_col..=max_col {
360 if let Some(cell) = cell_at(line, col, grid, scrollback) {
361 if cell.is_wide_continuation() {
362 if col == min_col {
365 line_buf.push(' ');
366 }
367 continue;
368 }
369 line_buf.push(cell.content());
373 if opts.include_combining {
374 for &mark in cell.combining_marks() {
375 line_buf.push(mark);
376 }
377 }
378 } else {
379 line_buf.push(' ');
380 }
381 }
382 if opts.trim_trailing {
383 trim_trailing_spaces(&mut line_buf);
384 }
385 out.push_str(&line_buf);
386
387 if line != end_line {
388 out.push('\n');
389 }
390 }
391
392 out
393 }
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401enum CharClass {
402 Word,
403 Whitespace,
404 Other,
405}
406
407fn classify_char(ch: char) -> CharClass {
408 if ch.is_whitespace() {
409 return CharClass::Whitespace;
410 }
411 if is_word_char(ch) {
412 return CharClass::Word;
413 }
414 CharClass::Other
415}
416
417fn is_word_char(ch: char) -> bool {
418 ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/' | '\\' | ':' | '@')
423}
424
425fn trim_trailing_spaces(s: &mut String) {
426 while s.ends_with(' ') {
427 s.pop();
428 }
429}
430
431fn total_lines(grid: &Grid, scrollback: &Scrollback) -> u32 {
432 (scrollback.len() + grid.rows() as usize) as u32
433}
434
435fn should_insert_newline(next_line: u32, scrollback: &Scrollback) -> bool {
436 let sb_len = scrollback.len() as u32;
437 if next_line < sb_len {
438 return !scrollback
440 .get(next_line as usize)
441 .map(|l| l.wrapped)
442 .unwrap_or(false);
443 }
444 true
445}
446
447fn cell_at<'a>(
448 line: u32,
449 col: u16,
450 grid: &'a Grid,
451 scrollback: &'a Scrollback,
452) -> Option<&'a Cell> {
453 let sb_len = scrollback.len() as u32;
454 if line < sb_len {
455 scrollback
456 .get(line as usize)
457 .and_then(|l| l.cells.get(col as usize))
458 } else {
459 let row = (line - sb_len) as u16;
460 grid.cell(row, col)
461 }
462}
463
464fn cell_char(line: u32, col: u16, grid: &Grid, scrollback: &Scrollback) -> Option<char> {
465 cell_at(line, col, grid, scrollback).map(Cell::content)
466}
467
468fn normalize_to_wide_lead(line: u32, col: u16, grid: &Grid, scrollback: &Scrollback) -> u16 {
469 if col == 0 {
470 return col;
471 }
472 let Some(cell) = cell_at(line, col, grid, scrollback) else {
473 return col;
474 };
475 if cell.is_wide_continuation() {
476 col - 1
477 } else {
478 col
479 }
480}
481
482fn wide_end_col(line: u32, lead_col: u16, grid: &Grid, scrollback: &Scrollback, cols: u16) -> u16 {
483 let Some(cell) = cell_at(line, lead_col, grid, scrollback) else {
484 return lead_col;
485 };
486 if cell.is_wide() {
487 lead_col.saturating_add(1).min(cols.saturating_sub(1))
489 } else {
490 lead_col
491 }
492}
493
494#[cfg(test)]
499mod tests {
500 use super::*;
501 use crate::cell::Cell;
502
503 fn grid_from_lines(cols: u16, lines: &[&str]) -> Grid {
504 let rows = lines.len() as u16;
505 let mut g = Grid::new(cols, rows);
506 for (r, text) in lines.iter().enumerate() {
507 for (c, ch) in text.chars().enumerate() {
508 if c >= cols as usize {
509 break;
510 }
511 g.cell_mut(r as u16, c as u16).unwrap().set_content(ch, 1);
512 }
513 }
514 g
515 }
516
517 fn scrollback_from_lines(lines: &[(&str, bool)]) -> Scrollback {
518 let mut sb = Scrollback::new(64);
519 for (text, wrapped) in lines {
520 let cells: Vec<Cell> = text.chars().map(Cell::new).collect();
521 sb.push_row(&cells, *wrapped);
522 }
523 sb
524 }
525
526 #[test]
527 fn extract_joins_soft_wrapped_scrollback_lines_without_newline() {
528 let sb = scrollback_from_lines(&[("foo", false), ("bar", true)]);
529 let grid = grid_from_lines(10, &["baz"]);
530 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(1, 2));
531 assert_eq!(sel.extract_text(&grid, &sb), "foobar");
532 }
533
534 #[test]
535 fn extract_spans_scrollback_and_viewport_with_newlines() {
536 let sb = scrollback_from_lines(&[("aa", false), ("bb", false)]);
537 let grid = grid_from_lines(10, &["cc", "dd"]);
538 let start = BufferPos::new(1, 0); let end = BufferPos::new(3, 1); let sel = Selection::new(start, end);
541 assert_eq!(sel.extract_text(&grid, &sb), "bb\ncc\ndd");
542 }
543
544 #[test]
545 fn word_selection_is_tuned_for_paths() {
546 let sb = Scrollback::new(0);
547 let grid = grid_from_lines(40, &["foo-bar/baz"]);
548 let sel = Selection::word_at(BufferPos::new(0, 4), &grid, &sb);
549 assert_eq!(sel.extract_text(&grid, &sb), "foo-bar/baz");
550 }
551
552 #[test]
553 fn word_selection_stops_at_whitespace() {
554 let sb = Scrollback::new(0);
555 let grid = grid_from_lines(40, &["abc def"]);
556 let sel = Selection::word_at(BufferPos::new(0, 5), &grid, &sb);
557 assert_eq!(sel.extract_text(&grid, &sb), "def");
558 }
559
560 #[test]
561 fn selection_coordinates_stay_valid_after_resize_with_scrollback_pull() {
562 let mut sb = scrollback_from_lines(&[("top", false)]);
563 let mut grid = grid_from_lines(10, &["aa", "bb"]);
564
565 let _new_cursor_row = grid.resize_with_scrollback(10, 3, 1, &mut sb);
567 assert_eq!(sb.len(), 0);
568 assert_eq!(grid.rows(), 3);
569
570 let start = BufferPos::from_viewport(sb.len(), 0, 0);
571 let end = BufferPos::from_viewport(sb.len(), 0, 2);
572 let sel = Selection::new(start, end);
573 assert_eq!(sel.extract_text(&grid, &sb), "top");
574 }
575
576 #[test]
579 fn buffer_pos_new_stores_line_and_col() {
580 let pos = BufferPos::new(42, 7);
581 assert_eq!(pos.line, 42);
582 assert_eq!(pos.col, 7);
583 }
584
585 #[test]
586 fn buffer_pos_from_viewport_adds_scrollback_offset() {
587 let pos = BufferPos::from_viewport(10, 3, 5);
588 assert_eq!(pos.line, 13); assert_eq!(pos.col, 5);
590 }
591
592 #[test]
593 fn buffer_pos_from_viewport_zero_scrollback() {
594 let pos = BufferPos::from_viewport(0, 0, 0);
595 assert_eq!(pos.line, 0);
596 assert_eq!(pos.col, 0);
597 }
598
599 #[test]
602 fn normalized_preserves_already_ordered_selection() {
603 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(1, 5));
604 let norm = sel.normalized();
605 assert_eq!(norm.start, sel.start);
606 assert_eq!(norm.end, sel.end);
607 }
608
609 #[test]
610 fn normalized_swaps_reversed_selection() {
611 let sel = Selection::new(BufferPos::new(3, 10), BufferPos::new(1, 2));
612 let norm = sel.normalized();
613 assert_eq!(norm.start, BufferPos::new(1, 2));
614 assert_eq!(norm.end, BufferPos::new(3, 10));
615 }
616
617 #[test]
618 fn normalized_swaps_same_line_reversed_cols() {
619 let sel = Selection::new(BufferPos::new(5, 8), BufferPos::new(5, 2));
620 let norm = sel.normalized();
621 assert_eq!(norm.start.col, 2);
622 assert_eq!(norm.end.col, 8);
623 }
624
625 #[test]
626 fn normalized_identity_when_equal() {
627 let pos = BufferPos::new(3, 3);
628 let sel = Selection::new(pos, pos);
629 let norm = sel.normalized();
630 assert_eq!(norm.start, pos);
631 assert_eq!(norm.end, pos);
632 }
633
634 #[test]
637 fn char_at_regular_char_selects_single_cell() {
638 let sb = Scrollback::new(0);
639 let grid = grid_from_lines(10, &["hello"]);
640 let sel = Selection::char_at(BufferPos::new(0, 2), &grid, &sb);
641 assert_eq!(sel.start.col, 2);
642 assert_eq!(sel.end.col, 2);
643 assert_eq!(sel.extract_text(&grid, &sb), "l");
644 }
645
646 #[test]
647 fn char_at_wide_char_expands_to_two_columns() {
648 let sb = Scrollback::new(0);
649 let mut grid = Grid::new(10, 1);
650 let (lead, cont) = Cell::wide('中', crate::cell::SgrAttrs::default());
651 *grid.cell_mut(0, 2).unwrap() = lead;
652 *grid.cell_mut(0, 3).unwrap() = cont;
653
654 let sel = Selection::char_at(BufferPos::new(0, 2), &grid, &sb);
656 assert_eq!(sel.start.col, 2);
657 assert_eq!(sel.end.col, 3);
658
659 let sel = Selection::char_at(BufferPos::new(0, 3), &grid, &sb);
661 assert_eq!(sel.start.col, 2);
662 assert_eq!(sel.end.col, 3);
663 }
664
665 #[test]
666 fn char_at_zero_cols_grid_returns_degenerate() {
667 let sb = Scrollback::new(0);
668 let grid = Grid::new(0, 1);
669 let pos = BufferPos::new(0, 0);
670 let sel = Selection::char_at(pos, &grid, &sb);
671 assert_eq!(sel.start, pos);
672 assert_eq!(sel.end, pos);
673 }
674
675 #[test]
676 fn char_at_clamps_col_beyond_grid_width() {
677 let sb = Scrollback::new(0);
678 let grid = grid_from_lines(5, &["abcde"]);
679 let sel = Selection::char_at(BufferPos::new(0, 99), &grid, &sb);
681 assert!(sel.start.col <= 4);
682 assert!(sel.end.col <= 4);
683 }
684
685 #[test]
688 fn line_at_selects_full_row_width() {
689 let sb = Scrollback::new(0);
690 let grid = grid_from_lines(8, &["hello "]);
691 let sel = Selection::line_at(0, &grid, &sb);
692 assert_eq!(sel.start.col, 0);
693 assert_eq!(sel.end.col, 7); assert_eq!(sel.start.line, 0);
695 assert_eq!(sel.end.line, 0);
696 }
697
698 #[test]
699 fn line_at_clamps_beyond_total_lines() {
700 let sb = Scrollback::new(0);
701 let grid = grid_from_lines(10, &["only"]);
702 let sel = Selection::line_at(999, &grid, &sb);
703 assert_eq!(sel.start.line, 0);
705 assert_eq!(sel.end.line, 0);
706 }
707
708 #[test]
709 fn line_at_scrollback_line() {
710 let sb = scrollback_from_lines(&[("sb-line", false)]);
711 let grid = grid_from_lines(10, &["vp-line"]);
712 let sel = Selection::line_at(0, &grid, &sb);
713 assert_eq!(sel.start.line, 0);
714 assert_eq!(sel.extract_text(&grid, &sb), "sb-line");
715 }
716
717 #[test]
718 fn line_at_zero_cols_grid() {
719 let sb = Scrollback::new(0);
720 let grid = Grid::new(0, 1);
721 let sel = Selection::line_at(0, &grid, &sb);
722 assert_eq!(sel.start, sel.end);
723 }
724
725 #[test]
728 fn word_at_punctuation_boundary() {
729 let sb = Scrollback::new(0);
730 let grid = grid_from_lines(20, &["hello(world)"]);
731 let sel = Selection::word_at(BufferPos::new(0, 0), &grid, &sb);
732 assert_eq!(sel.extract_text(&grid, &sb), "hello");
733 }
734
735 #[test]
736 fn word_at_selects_whitespace_run() {
737 let sb = Scrollback::new(0);
738 let grid = grid_from_lines(20, &["a b"]);
739 let sel = Selection::word_at(BufferPos::new(0, 2), &grid, &sb);
740 assert_eq!(sel.start.col, 1);
743 assert_eq!(sel.end.col, 3);
744 }
745
746 #[test]
747 fn word_at_single_char_line() {
748 let sb = Scrollback::new(0);
749 let grid = grid_from_lines(5, &["x"]);
750 let sel = Selection::word_at(BufferPos::new(0, 0), &grid, &sb);
751 assert_eq!(sel.extract_text(&grid, &sb), "x");
752 }
753
754 #[test]
755 fn word_at_empty_grid() {
756 let sb = Scrollback::new(0);
757 let grid = Grid::new(0, 0);
758 let pos = BufferPos::new(0, 0);
759 let sel = Selection::word_at(pos, &grid, &sb);
760 assert_eq!(sel.start, pos);
761 assert_eq!(sel.end, pos);
762 }
763
764 #[test]
765 fn word_at_url_characters() {
766 let sb = Scrollback::new(0);
767 let grid = grid_from_lines(40, &["see https://example.com:8080/path ok"]);
768 let sel = Selection::word_at(BufferPos::new(0, 10), &grid, &sb);
769 assert_eq!(
770 sel.extract_text(&grid, &sb),
771 "https://example.com:8080/path"
772 );
773 }
774
775 #[test]
778 fn extract_text_trims_trailing_spaces() {
779 let sb = Scrollback::new(0);
780 let grid = grid_from_lines(10, &["hi "]);
781 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(0, 9));
782 assert_eq!(sel.extract_text(&grid, &sb), "hi");
783 }
784
785 #[test]
786 fn extract_text_empty_grid_returns_empty() {
787 let sb = Scrollback::new(0);
788 let grid = Grid::new(0, 0);
789 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(0, 0));
790 assert_eq!(sel.extract_text(&grid, &sb), "");
791 }
792
793 #[test]
794 fn extract_text_reversed_selection_still_works() {
795 let sb = Scrollback::new(0);
796 let grid = grid_from_lines(10, &["abcdef"]);
797 let sel = Selection::new(BufferPos::new(0, 4), BufferPos::new(0, 1));
799 assert_eq!(sel.extract_text(&grid, &sb), "bcde");
800 }
801
802 #[test]
803 fn extract_text_single_cell() {
804 let sb = Scrollback::new(0);
805 let grid = grid_from_lines(5, &["hello"]);
806 let sel = Selection::new(BufferPos::new(0, 2), BufferPos::new(0, 2));
807 assert_eq!(sel.extract_text(&grid, &sb), "l");
808 }
809
810 #[test]
811 fn extract_text_wide_char_not_doubled() {
812 let sb = Scrollback::new(0);
813 let mut grid = Grid::new(10, 1);
814 let (lead, cont) = Cell::wide('漢', crate::cell::SgrAttrs::default());
815 *grid.cell_mut(0, 0).unwrap() = lead;
816 *grid.cell_mut(0, 1).unwrap() = cont;
817 grid.cell_mut(0, 2).unwrap().set_content('x', 1);
818
819 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(0, 2));
820 let text = sel.extract_text(&grid, &sb);
821 assert_eq!(text, "漢x");
823 }
824
825 #[test]
826 fn extract_text_multiline_with_trailing_trim() {
827 let sb = Scrollback::new(0);
828 let grid = grid_from_lines(10, &["abc ", "def "]);
829 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(1, 9));
830 assert_eq!(sel.extract_text(&grid, &sb), "abc\ndef");
831 }
832
833 #[test]
834 fn extract_text_out_of_bounds_clamped() {
835 let sb = Scrollback::new(0);
836 let grid = grid_from_lines(5, &["hi"]);
837 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(99, 99));
839 let text = sel.extract_text(&grid, &sb);
841 assert!(text.contains("hi"));
842 }
843
844 #[test]
847 fn classify_char_word_letters_digits_underscore() {
848 assert_eq!(classify_char('a'), CharClass::Word);
849 assert_eq!(classify_char('Z'), CharClass::Word);
850 assert_eq!(classify_char('5'), CharClass::Word);
851 assert_eq!(classify_char('_'), CharClass::Word);
852 }
853
854 #[test]
855 fn classify_char_word_path_chars() {
856 assert_eq!(classify_char('-'), CharClass::Word);
857 assert_eq!(classify_char('.'), CharClass::Word);
858 assert_eq!(classify_char('/'), CharClass::Word);
859 assert_eq!(classify_char('\\'), CharClass::Word);
860 assert_eq!(classify_char(':'), CharClass::Word);
861 assert_eq!(classify_char('@'), CharClass::Word);
862 }
863
864 #[test]
865 fn classify_char_whitespace() {
866 assert_eq!(classify_char(' '), CharClass::Whitespace);
867 assert_eq!(classify_char('\t'), CharClass::Whitespace);
868 assert_eq!(classify_char('\n'), CharClass::Whitespace);
869 }
870
871 #[test]
872 fn classify_char_other_punctuation() {
873 assert_eq!(classify_char('('), CharClass::Other);
874 assert_eq!(classify_char(')'), CharClass::Other);
875 assert_eq!(classify_char('{'), CharClass::Other);
876 assert_eq!(classify_char('!'), CharClass::Other);
877 assert_eq!(classify_char('#'), CharClass::Other);
878 }
879
880 #[test]
881 fn is_word_char_accepts_identifiers_and_paths() {
882 assert!(is_word_char('a'));
883 assert!(is_word_char('0'));
884 assert!(is_word_char('_'));
885 assert!(is_word_char('-'));
886 assert!(is_word_char('.'));
887 assert!(is_word_char('/'));
888 assert!(is_word_char('\\'));
889 assert!(is_word_char(':'));
890 assert!(is_word_char('@'));
891 }
892
893 #[test]
894 fn is_word_char_rejects_punctuation_and_whitespace() {
895 assert!(!is_word_char(' '));
896 assert!(!is_word_char('('));
897 assert!(!is_word_char(')'));
898 assert!(!is_word_char('{'));
899 assert!(!is_word_char('\t'));
900 assert!(!is_word_char('!'));
901 }
902
903 #[test]
904 fn trim_trailing_spaces_removes_only_trailing() {
905 let mut s = String::from(" hello ");
906 trim_trailing_spaces(&mut s);
907 assert_eq!(s, " hello");
908 }
909
910 #[test]
911 fn trim_trailing_spaces_noop_for_no_trailing() {
912 let mut s = String::from("hello");
913 trim_trailing_spaces(&mut s);
914 assert_eq!(s, "hello");
915 }
916
917 #[test]
918 fn trim_trailing_spaces_empties_all_spaces() {
919 let mut s = String::from(" ");
920 trim_trailing_spaces(&mut s);
921 assert_eq!(s, "");
922 }
923
924 #[test]
927 fn copy_options_default_values() {
928 let opts = CopyOptions::default();
929 assert!(opts.include_combining);
930 assert!(opts.trim_trailing);
931 assert!(opts.join_soft_wraps);
932 }
933
934 #[test]
937 fn extract_copy_includes_combining_marks() {
938 let sb = Scrollback::new(0);
939 let mut grid = Grid::new(10, 1);
940 grid.cell_mut(0, 0).unwrap().set_content('e', 1);
941 grid.cell_mut(0, 0).unwrap().push_combining('\u{0301}'); grid.cell_mut(0, 1).unwrap().set_content('x', 1);
943
944 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(0, 1));
945 let opts = CopyOptions::default();
946 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "e\u{0301}x");
947 }
948
949 #[test]
950 fn extract_copy_excludes_combining_when_disabled() {
951 let sb = Scrollback::new(0);
952 let mut grid = Grid::new(10, 1);
953 grid.cell_mut(0, 0).unwrap().set_content('e', 1);
954 grid.cell_mut(0, 0).unwrap().push_combining('\u{0301}');
955 grid.cell_mut(0, 1).unwrap().set_content('x', 1);
956
957 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(0, 1));
958 let opts = CopyOptions {
959 include_combining: false,
960 ..Default::default()
961 };
962 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "ex");
963 }
964
965 #[test]
966 fn extract_copy_multiple_combining_marks() {
967 let sb = Scrollback::new(0);
968 let mut grid = Grid::new(10, 1);
969 grid.cell_mut(0, 0).unwrap().set_content('o', 1);
970 grid.cell_mut(0, 0).unwrap().push_combining('\u{0308}'); grid.cell_mut(0, 0).unwrap().push_combining('\u{0301}'); let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(0, 0));
974 let opts = CopyOptions::default();
975 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "o\u{0308}\u{0301}");
976 }
977
978 #[test]
981 fn extract_copy_wide_char_not_doubled() {
982 let sb = Scrollback::new(0);
983 let mut grid = Grid::new(10, 1);
984 let (lead, cont) = Cell::wide('漢', crate::cell::SgrAttrs::default());
985 *grid.cell_mut(0, 0).unwrap() = lead;
986 *grid.cell_mut(0, 1).unwrap() = cont;
987 grid.cell_mut(0, 2).unwrap().set_content('x', 1);
988
989 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(0, 2));
990 let opts = CopyOptions::default();
991 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "漢x");
992 }
993
994 #[test]
995 fn extract_copy_consecutive_wide_chars() {
996 let sb = Scrollback::new(0);
997 let mut grid = Grid::new(10, 1);
998 let (lead1, cont1) = Cell::wide('中', crate::cell::SgrAttrs::default());
999 let (lead2, cont2) = Cell::wide('文', crate::cell::SgrAttrs::default());
1000 *grid.cell_mut(0, 0).unwrap() = lead1;
1001 *grid.cell_mut(0, 1).unwrap() = cont1;
1002 *grid.cell_mut(0, 2).unwrap() = lead2;
1003 *grid.cell_mut(0, 3).unwrap() = cont2;
1004
1005 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(0, 3));
1006 let opts = CopyOptions::default();
1007 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "中文");
1008 }
1009
1010 #[test]
1013 fn extract_copy_joins_soft_wrapped_lines() {
1014 let sb = scrollback_from_lines(&[("hello", false), ("world", true)]);
1015 let grid = grid_from_lines(10, &["end"]);
1016 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(1, 4));
1017 let opts = CopyOptions::default();
1018 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "helloworld");
1019 }
1020
1021 #[test]
1022 fn extract_copy_no_join_when_disabled() {
1023 let sb = scrollback_from_lines(&[("hello", false), ("world", true)]);
1024 let grid = grid_from_lines(10, &["end"]);
1025 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(1, 4));
1026 let opts = CopyOptions {
1027 join_soft_wraps: false,
1028 ..Default::default()
1029 };
1030 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "hello\nworld");
1031 }
1032
1033 #[test]
1034 fn extract_copy_hard_break_always_newline() {
1035 let sb = scrollback_from_lines(&[("aaa", false), ("bbb", false)]);
1036 let grid = grid_from_lines(10, &["ccc"]);
1037 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(2, 2));
1038 let opts = CopyOptions::default();
1039 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "aaa\nbbb\nccc");
1040 }
1041
1042 #[test]
1045 fn extract_copy_trims_trailing_by_default() {
1046 let sb = Scrollback::new(0);
1047 let grid = grid_from_lines(10, &["hi "]);
1048 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(0, 9));
1049 let opts = CopyOptions::default();
1050 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "hi");
1051 }
1052
1053 #[test]
1054 fn extract_copy_preserves_trailing_when_disabled() {
1055 let sb = Scrollback::new(0);
1056 let grid = grid_from_lines(5, &["ab"]);
1057 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(0, 4));
1058 let opts = CopyOptions {
1059 trim_trailing: false,
1060 ..Default::default()
1061 };
1062 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "ab ");
1063 }
1064
1065 #[test]
1068 fn extract_rect_basic_column_selection() {
1069 let sb = Scrollback::new(0);
1070 let grid = grid_from_lines(10, &["abcdef", "ghijkl", "mnopqr"]);
1071 let sel = Selection::new(BufferPos::new(0, 2), BufferPos::new(2, 4));
1073 let opts = CopyOptions::default();
1074 let text = sel.extract_rect(&grid, &sb, &opts);
1075 assert_eq!(text, "cde\nijk\nopr");
1076 }
1077
1078 #[test]
1079 fn extract_rect_single_column() {
1080 let sb = Scrollback::new(0);
1081 let grid = grid_from_lines(10, &["abc", "def", "ghi"]);
1082 let sel = Selection::new(BufferPos::new(0, 1), BufferPos::new(2, 1));
1083 let opts = CopyOptions::default();
1084 let text = sel.extract_rect(&grid, &sb, &opts);
1085 assert_eq!(text, "b\ne\nh");
1086 }
1087
1088 #[test]
1089 fn extract_rect_with_wide_char_leading_inside() {
1090 let sb = Scrollback::new(0);
1091 let mut grid = Grid::new(10, 2);
1092 grid.cell_mut(0, 0).unwrap().set_content('a', 1);
1094 let (lead, cont) = Cell::wide('中', crate::cell::SgrAttrs::default());
1095 *grid.cell_mut(0, 1).unwrap() = lead;
1096 *grid.cell_mut(0, 2).unwrap() = cont;
1097 grid.cell_mut(0, 3).unwrap().set_content('b', 1);
1098 grid.cell_mut(1, 0).unwrap().set_content('x', 1);
1100 grid.cell_mut(1, 1).unwrap().set_content('y', 1);
1101 grid.cell_mut(1, 2).unwrap().set_content('z', 1);
1102 grid.cell_mut(1, 3).unwrap().set_content('w', 1);
1103
1104 let sel = Selection::new(BufferPos::new(0, 1), BufferPos::new(1, 3));
1106 let opts = CopyOptions::default();
1107 let text = sel.extract_rect(&grid, &sb, &opts);
1108 assert_eq!(text, "中b\nyzw");
1111 }
1112
1113 #[test]
1114 fn extract_rect_continuation_at_left_boundary() {
1115 let sb = Scrollback::new(0);
1116 let mut grid = Grid::new(10, 1);
1117 let (lead, cont) = Cell::wide('漢', crate::cell::SgrAttrs::default());
1119 *grid.cell_mut(0, 0).unwrap() = lead;
1120 *grid.cell_mut(0, 1).unwrap() = cont;
1121 grid.cell_mut(0, 2).unwrap().set_content('x', 1);
1122
1123 let sel = Selection::new(BufferPos::new(0, 1), BufferPos::new(0, 2));
1124 let opts = CopyOptions::default();
1125 let text = sel.extract_rect(&grid, &sb, &opts);
1126 assert_eq!(text, " x");
1129 }
1130
1131 #[test]
1132 fn extract_rect_trims_trailing_spaces() {
1133 let sb = Scrollback::new(0);
1134 let grid = grid_from_lines(10, &["ab ", "cd "]);
1135 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(1, 5));
1136 let opts = CopyOptions::default();
1137 let text = sel.extract_rect(&grid, &sb, &opts);
1138 assert_eq!(text, "ab\ncd");
1139 }
1140
1141 #[test]
1142 fn extract_rect_with_combining_marks() {
1143 let sb = Scrollback::new(0);
1144 let mut grid = Grid::new(10, 2);
1145 grid.cell_mut(0, 0).unwrap().set_content('e', 1);
1146 grid.cell_mut(0, 0).unwrap().push_combining('\u{0301}');
1147 grid.cell_mut(0, 1).unwrap().set_content('x', 1);
1148 grid.cell_mut(1, 0).unwrap().set_content('a', 1);
1149 grid.cell_mut(1, 1).unwrap().set_content('b', 1);
1150
1151 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(1, 1));
1152 let opts = CopyOptions::default();
1153 let text = sel.extract_rect(&grid, &sb, &opts);
1154 assert_eq!(text, "e\u{0301}x\nab");
1155 }
1156
1157 #[test]
1158 fn extract_rect_no_soft_wrap_joining() {
1159 let sb = scrollback_from_lines(&[("abcdef", false), ("ghijkl", true)]);
1162 let grid = grid_from_lines(10, &[""]);
1163 let sel = Selection::new(BufferPos::new(0, 1), BufferPos::new(1, 3));
1164 let opts = CopyOptions::default();
1165 let text = sel.extract_rect(&grid, &sb, &opts);
1166 assert_eq!(text, "bcd\nhij");
1167 }
1168
1169 #[test]
1172 fn extract_copy_empty_grid() {
1173 let sb = Scrollback::new(0);
1174 let grid = Grid::new(0, 0);
1175 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(0, 0));
1176 let opts = CopyOptions::default();
1177 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "");
1178 }
1179
1180 #[test]
1181 fn extract_rect_empty_grid() {
1182 let sb = Scrollback::new(0);
1183 let grid = Grid::new(0, 0);
1184 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(0, 0));
1185 let opts = CopyOptions::default();
1186 assert_eq!(sel.extract_rect(&grid, &sb, &opts), "");
1187 }
1188
1189 #[test]
1190 fn extract_copy_reversed_selection() {
1191 let sb = Scrollback::new(0);
1192 let grid = grid_from_lines(10, &["abcdef"]);
1193 let sel = Selection::new(BufferPos::new(0, 4), BufferPos::new(0, 1));
1194 let opts = CopyOptions::default();
1195 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "bcde");
1196 }
1197
1198 #[test]
1199 fn extract_copy_single_cell() {
1200 let sb = Scrollback::new(0);
1201 let grid = grid_from_lines(5, &["hello"]);
1202 let sel = Selection::new(BufferPos::new(0, 2), BufferPos::new(0, 2));
1203 let opts = CopyOptions::default();
1204 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "l");
1205 }
1206
1207 #[test]
1208 fn extract_copy_scrollback_and_viewport() {
1209 let sb = scrollback_from_lines(&[("sb0", false), ("sb1", false)]);
1210 let grid = grid_from_lines(10, &["vp0", "vp1"]);
1211 let sel = Selection::new(BufferPos::new(1, 0), BufferPos::new(3, 2));
1212 let opts = CopyOptions::default();
1213 assert_eq!(sel.extract_copy(&grid, &sb, &opts), "sb1\nvp0\nvp1");
1214 }
1215
1216 #[test]
1217 fn extract_copy_deterministic() {
1218 let sb = scrollback_from_lines(&[("hello", false), ("world", true)]);
1219 let grid = grid_from_lines(10, &["end"]);
1220 let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(2, 2));
1221 let opts = CopyOptions::default();
1222 let a = sel.extract_copy(&grid, &sb, &opts);
1223 let b = sel.extract_copy(&grid, &sb, &opts);
1224 assert_eq!(a, b, "extract_copy must be deterministic");
1225 }
1226}