Skip to main content

frankenterm_core/
selection.rs

1#![forbid(unsafe_code)]
2//! Selection model + copy extraction for terminal grid + scrollback.
3//!
4//! This is a pure data/logic layer:
5//! - no I/O
6//! - deterministic output given the same buffer state
7//!
8//! Selection coordinates are defined over the *combined* buffer:
9//! `0..scrollback.len()` are scrollback lines (oldest → newest), followed by
10//! `grid.rows()` viewport lines (top → bottom).
11
12use crate::cell::Cell;
13use crate::grid::Grid;
14use crate::scrollback::Scrollback;
15
16/// A cell position in the combined buffer (scrollback + viewport).
17///
18/// - `line`: 0-indexed line index in the combined buffer.
19/// - `col`:  0-indexed column in the current viewport coordinate space.
20#[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    /// Convert a viewport (row, col) into a combined-buffer position.
33    #[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/// Inclusive selection over the combined buffer.
43///
44/// Invariant: after normalization, `(start.line, start.col) <= (end.line, end.col)`.
45#[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    /// Normalize start/end ordering.
58    #[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    /// Select exactly one character cell (wide chars expand to include both columns).
71    #[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    /// Select the whole logical line (all columns).
89    #[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    /// Select a "word" at the given position.
105    ///
106    /// Heuristics: contiguous run of `is_word_char` characters, or contiguous
107    /// whitespace if the clicked cell is whitespace.
108    #[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        // Seed selection with the current char span.
124        let mut start_col = col;
125        let mut end_col = wide_end_col(line, col, grid, scrollback, cols);
126
127        // Expand left.
128        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        // Expand right.
139        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    /// Extract selected text from the buffer (scrollback + viewport).
162    ///
163    /// - Wide continuation cells are skipped (wide chars appear once).
164    /// - Trailing spaces on each emitted line are trimmed.
165    /// - Soft-wrapped scrollback lines (where the *next* line has `wrapped=true`)
166    ///   are joined without inserting a newline.
167    #[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/// Options controlling copy extraction semantics.
223///
224/// Configures how text is extracted from a selection for clipboard/copy
225/// operations, including combining mark inclusion, soft-wrap joining,
226/// and trailing whitespace handling.
227#[derive(Debug, Clone)]
228pub struct CopyOptions {
229    /// Include Unicode combining marks in extracted text.
230    ///
231    /// When `true`, combining marks (accents, diacritics) attached to
232    /// base characters are included in the output. When `false`, only
233    /// the base character is emitted.
234    pub include_combining: bool,
235    /// Trim trailing whitespace from each extracted line.
236    pub trim_trailing: bool,
237    /// Join soft-wrapped scrollback lines without inserting a newline.
238    ///
239    /// When `true`, consecutive lines where the next has `wrapped=true`
240    /// are joined directly. When `false`, every line boundary becomes
241    /// a newline regardless of wrap status.
242    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    /// Extract text with configurable copy options.
257    ///
258    /// Enhanced version of [`extract_text`](Self::extract_text) that handles:
259    /// - Unicode combining marks (grapheme cluster preservation)
260    /// - Wide-character deduplication (continuation cells skipped)
261    /// - Soft-wrap joining (wrapped scrollback lines merged)
262    /// - Trailing whitespace trimming
263    #[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    /// Extract text for a rectangular (block/column) selection.
332    ///
333    /// Each line contributes only the columns between min_col and max_col
334    /// of the normalized selection. Lines are always separated by newlines
335    /// (soft-wrap joining does not apply to rectangular selections since
336    /// the column slice semantics would be ambiguous across wrapped runs).
337    #[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 the leading cell is outside the rectangle, emit a
363                        // space placeholder to preserve column alignment.
364                        if col == min_col {
365                            line_buf.push(' ');
366                        }
367                        continue;
368                    }
369                    // If this is a wide char whose continuation falls outside
370                    // the rectangle, still emit the character (visual intent
371                    // is to include it since the leading cell is selected).
372                    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// ---------------------------------------------------------------------------
397// Helpers
398// ---------------------------------------------------------------------------
399
400#[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    // Tuned for "code + paths" selection.
419    //
420    // - Identifiers: letters/digits/underscore
421    // - Paths/URLs:  - . / \\ : @
422    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        // wrapped=true means "this line continues the previous line".
439        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        // Include the continuation column when available.
488        lead_col.saturating_add(1).min(cols.saturating_sub(1))
489    } else {
490        lead_col
491    }
492}
493
494// ---------------------------------------------------------------------------
495// Tests
496// ---------------------------------------------------------------------------
497
498#[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); // "bb"
539        let end = BufferPos::new(3, 1); // "dd" (viewport row 1)
540        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        // Grow height: should pull the newest scrollback line into the top row.
566        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    // ── BufferPos tests ─────────────────────────────────────────────
577
578    #[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); // scrollback_lines(10) + row(3)
589        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    // ── Selection::normalized tests ─────────────────────────────────
600
601    #[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    // ── Selection::char_at tests ────────────────────────────────────
635
636    #[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        // Click on leading cell
655        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        // Click on continuation cell → should snap back to lead
660        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        // Col 99 should be clamped to max valid col (4)
680        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    // ── Selection::line_at tests ────────────────────────────────────
686
687    #[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); // cols - 1
694        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        // Clamped to line 0 (the only line)
704        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    // ── Selection::word_at additional tests ──────────────────────────
726
727    #[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        // Whitespace run spans cols 1..3 (between 'a' at 0 and 'b' at 4).
741        // extract_text trims trailing spaces, so verify boundaries directly.
742        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    // ── Selection::extract_text additional tests ────────────────────
776
777    #[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        // Reversed: end before start
798        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        // Wide char appears once, continuation cell skipped
822        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        // Selection extends beyond grid bounds
838        let sel = Selection::new(BufferPos::new(0, 0), BufferPos::new(99, 99));
839        // Should not panic; clamped to last valid line
840        let text = sel.extract_text(&grid, &sb);
841        assert!(text.contains("hi"));
842    }
843
844    // ── Helper function tests ───────────────────────────────────────
845
846    #[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    // ── CopyOptions default tests ────────────────────────────────────
925
926    #[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    // ── extract_copy: combining marks ────────────────────────────────
935
936    #[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}'); // acute accent
942        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}'); // diaeresis
971        grid.cell_mut(0, 0).unwrap().push_combining('\u{0301}'); // acute
972
973        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    // ── extract_copy: wide characters ────────────────────────────────
979
980    #[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    // ── extract_copy: soft-wrap joining ──────────────────────────────
1011
1012    #[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    // ── extract_copy: trailing whitespace ────────────────────────────
1043
1044    #[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    // ── extract_rect: rectangular selection ──────────────────────────
1066
1067    #[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        // Rectangle: cols 2..4 across rows 0..2
1072        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        // Row 0: a + wide '中' at cols 1-2 + b at col 3
1093        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        // Row 1: x y z w
1099        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        // Rectangle: cols 1..3 across rows 0..1
1105        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        // Row 0: col 1 = wide lead '中', col 2 = continuation (skip), col 3 = 'b'
1109        // Row 1: col 1 = 'y', col 2 = 'z', col 3 = 'w'
1110        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        // Wide char at cols 0-1, rect starts at col 1 (continuation)
1118        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        // Col 1 is continuation → space placeholder, col 2 = 'x'
1127        // But trim_trailing is on, and the space is leading not trailing
1128        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        // Rectangular selections always use newlines between lines,
1160        // even when scrollback lines are soft-wrapped.
1161        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    // ── extract_copy: edge cases ─────────────────────────────────────
1170
1171    #[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}