Skip to main content

frankenterm_core/
selection_state.rs

1#![forbid(unsafe_code)]
2//! Selection state machine for interactive selection lifecycle.
3//!
4//! Manages the transition from no-selection → selecting → active selection,
5//! handling mouse-driven drag, word/line expansion, and rectangular mode.
6//!
7//! # State transitions
8//!
9//! ```text
10//!                ┌──────────────────────────────────────┐
11//!                │                                      │
12//!  ┌──────┐     │  ┌───────────┐   Commit  ┌────────┐  │
13//!  │ None ├─────┼─▶│ Selecting ├──────────▶│ Active │──┘
14//!  └──┬───┘     │  └─────┬─────┘           └───┬────┘  Cancel
15//!     ▲         │        │ Cancel               │
16//!     │         │        ▼                      │
17//!     └─────────┼────────────────────────────────
18//!               │          Cancel
19//!               └──────────────────────────────────
20//! ```
21//!
22//! # Invariants
23//!
24//! 1. `current_selection()` always returns a normalized selection (start ≤ end).
25//! 2. All state transitions are deterministic for fixed inputs.
26//! 3. No I/O — this is a pure data/logic layer.
27//! 4. Wide-character boundaries are respected when the grid is available.
28
29use crate::grid::Grid;
30use crate::scrollback::Scrollback;
31use crate::selection::{BufferPos, CopyOptions, Selection};
32
33/// Selection granularity (click count determines expansion).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum SelectionGranularity {
36    /// Character-level (single click + drag).
37    Character,
38    /// Word-level (double click).
39    Word,
40    /// Line-level (triple click).
41    Line,
42}
43
44/// Selection shape.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum SelectionShape {
47    /// Linear (stream) selection: flows across line boundaries.
48    Linear,
49    /// Rectangular (block/column) selection: fixed column range across rows.
50    Rectangular,
51}
52
53/// Phase of the interactive selection lifecycle.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum SelectionPhase {
56    /// No selection active.
57    None,
58    /// Mouse button down, drag in progress.
59    Selecting,
60    /// Selection committed (mouse released).
61    Active,
62}
63
64/// Deterministic selection state machine.
65///
66/// Tracks the interactive lifecycle of a selection over the combined
67/// terminal buffer (scrollback + viewport). All methods are pure:
68/// given the same inputs, they produce the same outputs.
69#[derive(Debug, Clone)]
70pub struct SelectionState {
71    /// Current phase.
72    phase: SelectionPhase,
73    /// Anchor position (where the drag started).
74    anchor: Option<BufferPos>,
75    /// Current selection (always normalized when exposed via accessor).
76    selection: Option<Selection>,
77    /// Selection granularity.
78    granularity: SelectionGranularity,
79    /// Selection shape.
80    shape: SelectionShape,
81}
82
83impl Default for SelectionState {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl SelectionState {
90    /// Create a new state machine with no active selection.
91    #[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    // -----------------------------------------------------------------------
103    // Accessors
104    // -----------------------------------------------------------------------
105
106    /// Current phase of the selection lifecycle.
107    #[must_use]
108    pub fn phase(&self) -> SelectionPhase {
109        self.phase
110    }
111
112    /// Returns the current selection, normalized (start ≤ end).
113    ///
114    /// Returns `None` when phase is `None`.
115    #[must_use]
116    pub fn current_selection(&self) -> Option<Selection> {
117        self.selection.map(|s| s.normalized())
118    }
119
120    /// Returns the raw (non-normalized) selection for display purposes.
121    #[must_use]
122    pub fn raw_selection(&self) -> Option<Selection> {
123        self.selection
124    }
125
126    /// Returns the anchor point (drag start).
127    #[must_use]
128    pub fn anchor(&self) -> Option<BufferPos> {
129        self.anchor
130    }
131
132    /// Current selection granularity.
133    #[must_use]
134    pub fn granularity(&self) -> SelectionGranularity {
135        self.granularity
136    }
137
138    /// Current selection shape.
139    #[must_use]
140    pub fn shape(&self) -> SelectionShape {
141        self.shape
142    }
143
144    /// Whether any selection is present (selecting or active).
145    #[must_use]
146    pub fn has_selection(&self) -> bool {
147        self.selection.is_some()
148    }
149
150    // -----------------------------------------------------------------------
151    // State transitions
152    // -----------------------------------------------------------------------
153
154    /// Begin a new selection at `pos`.
155    ///
156    /// Transitions: None → Selecting, Active → Selecting.
157    /// Clears any previous selection.
158    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    /// Begin a new selection with a specific shape.
166    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    /// Update the selection endpoint during drag.
177    ///
178    /// Only valid when `phase == Selecting`.
179    /// The selection is updated from anchor to `pos`.
180    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    /// Update the selection with grid-aware expansion.
190    ///
191    /// When granularity is Word or Line, expands the selection from the
192    /// anchor word/line to the word/line containing `pos`.
193    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                // Union of both word selections
209                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    /// Commit the current selection (mouse released).
246    ///
247    /// Transitions: Selecting → Active.
248    /// No-op if not selecting.
249    pub fn commit(&mut self) {
250        if self.phase == SelectionPhase::Selecting {
251            self.phase = SelectionPhase::Active;
252        }
253    }
254
255    /// Cancel and clear the selection.
256    ///
257    /// Transitions: Any → None.
258    pub fn cancel(&mut self) {
259        self.phase = SelectionPhase::None;
260        self.anchor = None;
261        self.selection = None;
262    }
263
264    /// Toggle between linear and rectangular selection.
265    ///
266    /// Preserves the current selection range, only changes shape.
267    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    // -----------------------------------------------------------------------
275    // Query helpers
276    // -----------------------------------------------------------------------
277
278    /// Check if a cell at (line, col) is within the current selection.
279    ///
280    /// Accounts for selection shape (linear vs rectangular).
281    #[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                    // Single line: check column range
294                    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 // middle lines fully selected
301                }
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    /// Extract text from the current selection using the grid and scrollback.
315    ///
316    /// Returns `None` if no selection is active.
317    /// For linear selections, delegates to [`Selection::extract_text`].
318    /// For rectangular selections, delegates to [`Selection::extract_rect`].
319    #[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    /// Extract text with explicit copy options.
330    ///
331    /// Shape-aware: dispatches to linear or rectangular extraction
332    /// based on the current selection shape.
333    #[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// ===========================================================================
349// Gesture Controller
350// ===========================================================================
351
352/// Direction for keyboard selection extension.
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum SelectionDirection {
355    Up,
356    Down,
357    Left,
358    Right,
359    /// Jump to start of line.
360    Home,
361    /// Jump to end of line.
362    End,
363    /// Jump one word left.
364    WordLeft,
365    /// Jump one word right.
366    WordRight,
367}
368
369/// Hint returned by the gesture controller when the pointer drags past
370/// the viewport boundary, indicating the host should auto-scroll.
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum AutoScrollHint {
373    /// Scroll up (pointer above viewport).
374    Up(u16),
375    /// Scroll down (pointer below viewport).
376    Down(u16),
377    /// No scrolling needed.
378    None,
379}
380
381/// Configuration for click-count detection thresholds.
382#[derive(Debug, Clone, Copy)]
383pub struct GestureConfig {
384    /// Maximum elapsed milliseconds between clicks for multi-click detection.
385    pub multi_click_threshold_ms: u64,
386    /// Maximum cell distance (Manhattan) for multi-click detection.
387    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/// Gesture controller that maps raw pointer and keyboard events to
400/// [`SelectionState`] transitions.
401///
402/// This is a pure data/logic layer — no I/O, deterministic for fixed inputs.
403///
404/// # Responsibilities
405///
406/// - **Click-count detection**: single → character, double → word, triple → line.
407/// - **Viewport → buffer mapping**: converts (viewport_row, col) to [`BufferPos`]
408///   accounting for scrollback offset.
409/// - **Modifier handling**: Shift extends selection, Alt toggles rectangular mode.
410/// - **Keyboard selection**: Shift+arrow keys extend the selection endpoint.
411/// - **Auto-scroll hints**: signals when drag goes past viewport edges.
412#[derive(Debug, Clone)]
413pub struct SelectionGestureController {
414    state: SelectionState,
415    /// Monotonic timestamp (ms) of the last mouse-down event.
416    last_click_time_ms: u64,
417    /// Buffer position of the last mouse-down event (for multi-click proximity).
418    last_click_pos: Option<BufferPos>,
419    /// Running click count (1, 2, or 3; wraps back to 1).
420    click_count: u8,
421    /// Gesture configuration.
422    config: GestureConfig,
423}
424
425impl Default for SelectionGestureController {
426    fn default() -> Self {
427        Self::new()
428    }
429}
430
431impl SelectionGestureController {
432    /// Create a new gesture controller with default configuration.
433    #[must_use]
434    pub fn new() -> Self {
435        Self::with_config(GestureConfig::default())
436    }
437
438    /// Create a new gesture controller with explicit configuration.
439    #[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    // -----------------------------------------------------------------------
451    // Accessors
452    // -----------------------------------------------------------------------
453
454    /// Access the underlying selection state.
455    #[must_use]
456    pub fn state(&self) -> &SelectionState {
457        &self.state
458    }
459
460    /// Mutable access to the underlying selection state.
461    pub fn state_mut(&mut self) -> &mut SelectionState {
462        &mut self.state
463    }
464
465    /// Check if a cell at (line, col) is within the current selection.
466    #[must_use]
467    pub fn contains(&self, line: u32, col: u16) -> bool {
468        self.state.contains(line, col)
469    }
470
471    /// Current selection phase.
472    #[must_use]
473    pub fn phase(&self) -> SelectionPhase {
474        self.state.phase()
475    }
476
477    /// Whether any selection is present.
478    #[must_use]
479    pub fn has_selection(&self) -> bool {
480        self.state.has_selection()
481    }
482
483    /// Returns the current normalized selection, if any.
484    #[must_use]
485    pub fn current_selection(&self) -> Option<Selection> {
486        self.state.current_selection()
487    }
488
489    /// Current selection shape.
490    #[must_use]
491    pub fn shape(&self) -> SelectionShape {
492        self.state.shape()
493    }
494
495    // -----------------------------------------------------------------------
496    // Coordinate mapping
497    // -----------------------------------------------------------------------
498
499    /// Convert a viewport coordinate to a combined-buffer [`BufferPos`].
500    ///
501    /// `scroll_offset_from_bottom` is how many scrollback lines the user has
502    /// scrolled up from the newest content. When 0, the viewport shows the
503    /// latest lines.
504    ///
505    /// In the combined buffer, scrollback lines occupy indices `0..scrollback_len`
506    /// and viewport grid lines occupy `scrollback_len..scrollback_len+viewport_rows`.
507    /// When scrolled up by `offset`, the viewport starts `offset` lines earlier:
508    ///   `buffer_line = scrollback_len - offset + viewport_row`
509    #[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    // -----------------------------------------------------------------------
524    // Pointer events
525    // -----------------------------------------------------------------------
526
527    /// Handle a mouse-down event.
528    ///
529    /// Returns the computed [`BufferPos`].
530    #[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        // Determine click count.
551        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        // Alt modifier toggles rectangular mode.
557        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            // Extend existing selection to new position.
573            self.extend_to(pos, grid, scrollback);
574        } else {
575            // Start new selection.
576            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    /// Handle a mouse-drag event during an active selection.
602    ///
603    /// Returns an [`AutoScrollHint`] if the pointer is outside the viewport.
604    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        // Detect auto-scroll.
618        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        // Clamp row to viewport bounds for position mapping.
628        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    /// Handle a mouse-up event, committing the selection.
643    pub fn mouse_up(&mut self) {
644        self.state.commit();
645    }
646
647    /// Cancel the current selection (e.g., Escape key).
648    pub fn cancel(&mut self) {
649        self.state.cancel();
650        self.click_count = 0;
651    }
652
653    // -----------------------------------------------------------------------
654    // Keyboard selection
655    // -----------------------------------------------------------------------
656
657    /// Extend the selection using a keyboard direction.
658    ///
659    /// If no selection exists, starts one at `cursor_pos`.
660    /// Moves the selection endpoint in `direction`.
661    ///
662    /// `cols`: grid column count for Home/End and line wrapping.
663    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 no selection, start at cursor position.
678        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        // The endpoint we extend is the one closest to the direction.
690        // We use the raw selection to know which end the user was last dragging.
691        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                    // Wrap to end of previous line.
700                    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                    // Wrap to start of next line.
710                    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                // Move left past any whitespace/punctuation, then past the word.
733                self.find_word_boundary_left(endpoint, cols, max_line)
734            }
735            SelectionDirection::WordRight => {
736                // Move right past current word, then past whitespace.
737                self.find_word_boundary_right(endpoint, cols, max_line)
738            }
739        };
740
741        // Re-start from the anchor and drag to the new endpoint.
742        let anchor = raw.start;
743        self.state.start(anchor, SelectionGranularity::Character);
744        self.state.drag(new_endpoint);
745        self.state.commit();
746    }
747
748    /// Select all content in the buffer.
749    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    // -----------------------------------------------------------------------
761    // Text extraction (delegation)
762    // -----------------------------------------------------------------------
763
764    /// Extract selected text using grid and scrollback.
765    #[must_use]
766    pub fn extract_text(&self, grid: &Grid, scrollback: &Scrollback) -> Option<String> {
767        self.state.extract_text(grid, scrollback)
768    }
769
770    /// Extract selected text with explicit copy options.
771    #[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    // -----------------------------------------------------------------------
782    // Internal helpers
783    // -----------------------------------------------------------------------
784
785    /// Resolve click count based on timing and proximity.
786    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                // Cycle: 1 → 2 → 3 → 1
797                return if self.click_count >= 3 {
798                    1
799                } else {
800                    self.click_count + 1
801                };
802            }
803        }
804        1
805    }
806
807    /// Extend the current selection endpoint to `pos`.
808    fn extend_to(&mut self, pos: BufferPos, grid: &Grid, scrollback: &Scrollback) {
809        // If we have an active selection, re-enter selecting mode from
810        // the original anchor and drag to the new position.
811        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    /// Find a word boundary to the left of `pos`.
820    fn find_word_boundary_left(&self, pos: BufferPos, _cols: u16, _max_line: u32) -> BufferPos {
821        // Simple heuristic: jump to start of current column word or previous column.
822        if pos.col > 0 {
823            // Jump by a fixed word size for now (keyboard word navigation
824            // without grid access). Host can refine later with grid data.
825            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    /// Find a word boundary to the right of `pos`.
835    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// ===========================================================================
849// Tests
850// ===========================================================================
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    fn pos(line: u32, col: u16) -> BufferPos {
857        BufferPos::new(line, col)
858    }
859
860    // -----------------------------------------------------------------------
861    // Phase transitions
862    // -----------------------------------------------------------------------
863
864    #[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        // Start new selection
919        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(); // None → commit = noop
929        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    // -----------------------------------------------------------------------
941    // Normalization invariant
942    // -----------------------------------------------------------------------
943
944    #[test]
945    fn selection_always_normalized() {
946        let mut state = SelectionState::new();
947        // Select backwards (end before start)
948        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        // Raw preserves anchor-first, drag-second order
968        assert_eq!(raw.start, pos(5, 10));
969        assert_eq!(raw.end, pos(2, 3));
970    }
971
972    // -----------------------------------------------------------------------
973    // Contains (linear)
974    // -----------------------------------------------------------------------
975
976    #[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        // Start line: only cols >= 10
999        assert!(!state.contains(2, 9));
1000        assert!(state.contains(2, 10));
1001        assert!(state.contains(2, 50));
1002        // Middle line: all cols
1003        assert!(state.contains(3, 0));
1004        assert!(state.contains(4, 100));
1005        // End line: only cols <= 20
1006        assert!(state.contains(5, 0));
1007        assert!(state.contains(5, 20));
1008        assert!(!state.contains(5, 21));
1009        // Outside
1010        assert!(!state.contains(1, 10));
1011        assert!(!state.contains(6, 0));
1012    }
1013
1014    // -----------------------------------------------------------------------
1015    // Contains (rectangular)
1016    // -----------------------------------------------------------------------
1017
1018    #[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        // Within rectangle
1029        assert!(state.contains(2, 5));
1030        assert!(state.contains(3, 10));
1031        assert!(state.contains(5, 15));
1032        // Outside column range
1033        assert!(!state.contains(3, 4));
1034        assert!(!state.contains(3, 16));
1035        // Outside row range
1036        assert!(!state.contains(1, 10));
1037        assert!(!state.contains(6, 10));
1038    }
1039
1040    // -----------------------------------------------------------------------
1041    // Shape toggle
1042    // -----------------------------------------------------------------------
1043
1044    #[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    // -----------------------------------------------------------------------
1055    // Default trait
1056    // -----------------------------------------------------------------------
1057
1058    #[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    // -----------------------------------------------------------------------
1070    // Determinism
1071    // -----------------------------------------------------------------------
1072
1073    #[test]
1074    fn deterministic_transitions() {
1075        // Same input sequence → same output (determinism invariant).
1076        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    // -----------------------------------------------------------------------
1087    // Shape-aware text extraction
1088    // -----------------------------------------------------------------------
1089
1090    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        // With combining marks
1148        let opts = CopyOptions::default();
1149        let text = state.extract_copy(&grid, &sb, &opts).unwrap();
1150        assert_eq!(text, "e\u{0301}x");
1151
1152        // Without combining marks
1153        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    // =======================================================================
1197    // Gesture Controller tests
1198    // =======================================================================
1199
1200    // ── Viewport → buffer mapping ─────────────────────────────────
1201
1202    #[test]
1203    fn viewport_to_buffer_no_scrollback_no_offset() {
1204        // No scrollback, row 0 col 5 → buffer line 0 col 5
1205        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        // 100 scrollback lines, viewport row 0 → buffer line 100
1213        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        // 100 scrollback lines, scrolled up 50 → viewport row 0 = buffer line 50
1220        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        // Row 5 in the viewport with 100 scrollback and offset 0
1227        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    // ── Click count detection ─────────────────────────────────────
1233
1234    #[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        // First click
1251        gc.mouse_down(0, 2, 1000, false, false, &grid, &sb, 0);
1252        gc.mouse_up();
1253        // Second click (within threshold)
1254        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        // Too much time → single click
1285        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        // Too far → single click
1298        gc.mouse_down(0, 10, 1200, false, false, &grid, &sb, 0);
1299        assert_eq!(gc.state().granularity(), SelectionGranularity::Character);
1300    }
1301
1302    // ── Mouse drag ────────────────────────────────────────────────
1303
1304    #[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    // ── Shift+click extends selection ─────────────────────────────
1352
1353    #[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        // First click at col 2
1360        gc.mouse_down(0, 2, 0, false, false, &grid, &sb, 0);
1361        gc.mouse_up();
1362        // Shift+click at col 7 → extends selection
1363        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    // ── Alt+click → rectangular selection ─────────────────────────
1373
1374    #[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    // ── Keyboard selection ────────────────────────────────────────
1389
1390    #[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        // Start at (1, 0) and go left → should wrap to end of previous line.
1456        // Normalized selection: start=(0,9) end=(1,0) since (0,9) < (1,0).
1457        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    // ── Select all ────────────────────────────────────────────────
1465
1466    #[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    // ── Cancel ────────────────────────────────────────────────────
1484
1485    #[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    // ── Determinism ───────────────────────────────────────────────
1501
1502    #[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    // ── Default trait ─────────────────────────────────────────────
1518
1519    #[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    // ── Config ────────────────────────────────────────────────────
1527
1528    #[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    // ── Click count wraps ─────────────────────────────────────────
1539
1540    #[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        // Fourth click → wraps to 1 (character)
1553        gc.mouse_down(0, 0, 400, false, false, &grid, &sb, 0);
1554        assert_eq!(gc.state().granularity(), SelectionGranularity::Character);
1555    }
1556}