Skip to main content

vtcode_ui/tui/core_tui/session/
mouse_selection.rs

1use ratatui::buffer::Buffer;
2use ratatui::crossterm::{Command, clipboard::CopyToClipboard};
3use ratatui::layout::Rect;
4use std::io::Write;
5#[cfg(test)]
6use std::path::PathBuf;
7#[cfg(test)]
8use std::sync::{Mutex, OnceLock};
9use std::time::{Duration, Instant};
10use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
11
12const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(450);
13
14/// Edge direction for auto-scroll while dragging a transcript selection.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub(crate) enum DragAutoScrollDirection {
17    Up,
18    Down,
19}
20
21/// Pending edge auto-scroll for an in-progress transcript drag.
22#[derive(Debug, Clone, Copy)]
23pub(crate) struct DragAutoScroll {
24    pub(crate) direction: DragAutoScrollDirection,
25    /// Pointer position clamped into the transcript area so the selection end
26    /// keeps extending onto newly revealed rows.
27    pub(crate) column: u16,
28    pub(crate) row: u16,
29    pub(crate) last_step: Instant,
30}
31
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33enum SelectionSurface {
34    #[default]
35    Transcript,
36    Overlay,
37}
38
39/// Tracks shared mouse-driven text selection for the transcript and overlays.
40#[derive(Debug, Default)]
41pub struct MouseSelectionState {
42    /// Whether the user is currently dragging to select text.
43    pub(crate) is_selecting: bool,
44    /// Retained after mouse-up, unlike the active drag target.
45    surface: SelectionSurface,
46    /// Screen coordinates where the selection started (column, row).
47    start: (u16, u16),
48    /// Screen coordinates where the selection currently ends (column, row).
49    end: (u16, u16),
50    /// Whether a completed selection exists (ready for highlight rendering).
51    pub(crate) has_selection: bool,
52    /// Whether the current selection has already been copied to clipboard.
53    copied: bool,
54    /// Whether Ctrl+C was pressed to explicitly copy the current selection.
55    copy_requested: bool,
56    /// Tracks the previous mouse click so double-clicks can be detected.
57    last_click: Option<ClickRecord>,
58}
59
60#[derive(Clone, Copy, Debug)]
61struct ClickRecord {
62    column: u16,
63    row: u16,
64    at: Instant,
65}
66
67impl MouseSelectionState {
68    pub(crate) fn new() -> Self {
69        Self::default()
70    }
71
72    /// Begin a new selection at the given screen position.
73    pub(crate) fn start_selection(&mut self, col: u16, row: u16) {
74        self.surface = SelectionSurface::Transcript;
75        self.is_selecting = true;
76        self.has_selection = false;
77        self.copied = false;
78        self.start = (col, row);
79        self.end = (col, row);
80    }
81
82    /// Begin an overlay selection that must not move with transcript geometry.
83    pub(crate) fn start_overlay_selection(&mut self, col: u16, row: u16) {
84        self.start_selection(col, row);
85        self.surface = SelectionSurface::Overlay;
86    }
87
88    pub(crate) fn is_transcript_selection(&self) -> bool {
89        self.surface == SelectionSurface::Transcript
90    }
91
92    /// Set a transcript selection directly, bypassing drag state.
93    pub(crate) fn set_selection(&mut self, start: (u16, u16), end: (u16, u16)) {
94        self.surface = SelectionSurface::Transcript;
95        self.is_selecting = false;
96        self.has_selection = start != end;
97        self.copied = false;
98        self.start = start;
99        self.end = end;
100    }
101
102    /// Update the end position while dragging.
103    pub(crate) fn update_selection(&mut self, col: u16, row: u16) {
104        if self.is_selecting {
105            self.end = (col, row);
106            self.has_selection = true;
107        }
108    }
109
110    /// Finalize the selection on mouse-up.
111    pub(crate) fn finish_selection(&mut self, col: u16, row: u16) {
112        if self.is_selecting {
113            self.end = (col, row);
114            self.is_selecting = false;
115            // Only mark as having a selection if start != end
116            self.has_selection = self.start != self.end;
117        }
118    }
119
120    /// Adjust transcript selection coordinates when the underlying content moves.
121    /// Overlay text retains its screen position during background scrolling.
122    ///
123    /// `row_delta` is positive when content moves down on screen (scroll up / showing
124    /// older content) and negative when content moves up (scroll down / showing newer
125    /// content).  If the adjustment pushes the selection completely off-screen the
126    /// selection is cleared.
127    pub(crate) fn adjust_for_scroll(&mut self, row_delta: i32) {
128        if !self.is_transcript_selection() || (!self.has_selection && !self.is_selecting) {
129            return;
130        }
131        if row_delta == 0 {
132            return;
133        }
134
135        let new_start_row = self.start.1 as i32 + row_delta;
136        let new_end_row = self.end.1 as i32 + row_delta;
137
138        // If both ends are completely off-screen in the same direction, clear.
139        // Clamp to screen bounds (0..=viewport_height, roughly 0..=u16::MAX).
140        // If after clamping both are the same and off-screen, or both are off-screen
141        // in a way that suggests selection is gone, clear.
142
143        let clamped_start = new_start_row.clamp(0, i32::from(u16::MAX));
144        let clamped_end = new_end_row.clamp(0, i32::from(u16::MAX));
145
146        // If the selection is now completely off-screen in a way that means
147        // the original selection range was entirely off-screen, clear.
148        if (new_start_row < 0 && new_end_row < 0)
149            || (new_start_row > i32::from(u16::MAX) && new_end_row > i32::from(u16::MAX))
150        {
151            self.is_selecting = false;
152            self.has_selection = false;
153            self.copied = false;
154            self.copy_requested = false;
155            return;
156        }
157
158        self.start.1 = clamped_start as u16;
159        self.end.1 = clamped_end as u16;
160    }
161
162    /// Clear any active selection.
163    pub(crate) fn clear(&mut self) {
164        self.surface = SelectionSurface::Transcript;
165        self.is_selecting = false;
166        self.has_selection = false;
167        self.copied = false;
168        self.copy_requested = false;
169        self.last_click = None;
170    }
171
172    /// Clears only the mouse click history used for double-click detection.
173    pub(crate) fn clear_click_history(&mut self) {
174        self.last_click = None;
175    }
176
177    /// Records a click and returns `true` when it matches the previous click closely enough
178    /// to be treated as a double click.
179    pub(crate) fn register_click(&mut self, col: u16, row: u16, at: Instant) -> bool {
180        let is_double_click = self.last_click.is_some_and(|last| {
181            last.column == col && last.row == row && at.saturating_duration_since(last.at) <= DOUBLE_CLICK_INTERVAL
182        });
183
184        self.last_click = Some(ClickRecord { column: col, row, at });
185        is_double_click
186    }
187
188    /// Returns the selection range normalized so that `from` is before `to`.
189    fn normalized(&self) -> ((u16, u16), (u16, u16)) {
190        let (s, e) = (self.start, self.end);
191        if s.1 < e.1 || (s.1 == e.1 && s.0 <= e.0) {
192            (s, e)
193        } else {
194            (e, s)
195        }
196    }
197
198    /// Extract selected text from a ratatui `Buffer`.
199    pub(crate) fn extract_text(&self, buf: &Buffer, area: Rect) -> String {
200        if !self.has_selection && !self.is_selecting {
201            return String::new();
202        }
203
204        // Clamp to the actual buffer area to avoid out-of-range buffer indexing panics.
205        let area = area.intersection(buf.area);
206        if area.width == 0 || area.height == 0 {
207            return String::new();
208        }
209
210        let ((start_col, start_row), (end_col, end_row)) = self.normalized();
211        let mut result = String::new();
212
213        for row in start_row..=end_row {
214            if row < area.y || row >= area.bottom() {
215                continue;
216            }
217            let line_start = if row == start_row {
218                start_col.max(area.x)
219            } else {
220                area.x
221            };
222            let line_end = if row == end_row {
223                end_col.min(area.right())
224            } else {
225                area.right()
226            };
227
228            for col in line_start..line_end {
229                if col < area.x || col >= area.right() {
230                    continue;
231                }
232                let cell = &buf[(col, row)];
233                let symbol = cell.symbol();
234                if !symbol.is_empty() {
235                    result.push_str(symbol);
236                }
237            }
238
239            // Add newline between rows (but not after the last)
240            if row < end_row {
241                // Trim trailing whitespace from each line
242                let trimmed = result.trim_end().len();
243                result.truncate(trimmed);
244                result.push('\n');
245            }
246        }
247
248        // Trim trailing whitespace from the final line
249        let trimmed = result.trim_end();
250        trimmed.to_string()
251    }
252
253    /// Apply selection highlight (inverted colors) to the frame buffer.
254    pub(crate) fn apply_highlight(&self, buf: &mut Buffer, area: Rect) {
255        if !self.has_selection && !self.is_selecting {
256            return;
257        }
258
259        // Clamp to the actual buffer area to avoid out-of-range buffer indexing panics.
260        let area = area.intersection(buf.area);
261        if area.width == 0 || area.height == 0 {
262            return;
263        }
264
265        let ((start_col, start_row), (end_col, end_row)) = self.normalized();
266
267        for row in start_row..=end_row {
268            if row < area.y || row >= area.bottom() {
269                continue;
270            }
271            let line_start = if row == start_row {
272                start_col.max(area.x)
273            } else {
274                area.x
275            };
276            let line_end = if row == end_row {
277                end_col.min(area.right())
278            } else {
279                area.right()
280            };
281
282            for col in line_start..line_end {
283                if col < area.x || col >= area.right() {
284                    continue;
285                }
286                let cell = &mut buf[(col, row)];
287                // Swap foreground and background to show selection
288                let fg = cell.fg;
289                let bg = cell.bg;
290                cell.set_fg(bg);
291                cell.set_bg(fg);
292            }
293        }
294    }
295
296    /// Returns `true` if the selection needs to be copied (finalized and not yet copied).
297    pub(crate) fn needs_copy(&self) -> bool {
298        self.has_selection && !self.is_selecting && !self.copied
299    }
300
301    /// Returns `true` if an explicit copy was requested via Ctrl+C.
302    pub(crate) fn has_copy_request(&self) -> bool {
303        self.copy_requested
304    }
305
306    /// Request an explicit copy of the current selection (triggered by Ctrl+C).
307    pub(crate) fn request_copy(&mut self) {
308        if self.has_selection {
309            self.copy_requested = true;
310        }
311    }
312
313    /// Mark the selection as already copied.
314    pub(crate) fn mark_copied(&mut self) {
315        self.copied = true;
316    }
317
318    /// Copy the selected text to the system clipboard.
319    ///
320    /// Tries native OS clipboard utilities first (`pbcopy` on macOS, `xclip`/`xsel`/`wl-copy`
321    /// on Linux, `clip.exe` on Windows/WSL) for maximum compatibility, then falls back to the
322    /// OSC 52 escape sequence. Returns whether any strategy reported success.
323    ///
324    /// OSC 52 delivery is best-effort: terminals never acknowledge the sequence, so a clean
325    /// stderr write counts as success even if the terminal silently drops it (unsupported,
326    /// disabled, or swallowed by a multiplexer). Every strategy attempt emits a
327    /// `tracing::debug!` event; enable `RUST_LOG=vtcode_ui=debug` when diagnosing.
328    pub fn copy_to_clipboard(text: &str) -> bool {
329        if text.is_empty() {
330            trace_clipboard("nothing to copy: empty text");
331            return false;
332        }
333
334        if Self::copy_via_native(text) {
335            return true;
336        }
337
338        let copied = copy_via_osc52(text, inside_tmux());
339        trace_clipboard(&format!(
340            "native candidates exhausted; osc52 fallback result={copied} text_bytes={}",
341            text.len()
342        ));
343        copied
344    }
345
346    /// Attempt to copy text using native OS clipboard utilities.
347    /// Returns `true` if successful.
348    fn copy_via_native(text: &str) -> bool {
349        use std::process::Command;
350
351        #[cfg(test)]
352        if let Some(program) = clipboard_command_override() {
353            return spawn_clipboard_command(Command::new(program), text);
354        }
355
356        let candidates: &[&str] = if cfg!(target_os = "macos") {
357            &["pbcopy"]
358        } else if cfg!(target_os = "linux") {
359            &["xclip", "xsel", "wl-copy"]
360        } else if cfg!(target_os = "windows") {
361            &["clip.exe"]
362        } else {
363            &[]
364        };
365
366        for program in candidates {
367            let mut cmd = Command::new(program);
368            match *program {
369                "xclip" => {
370                    cmd.arg("-selection").arg("clipboard");
371                }
372                "xsel" => {
373                    cmd.arg("--clipboard").arg("--input");
374                }
375                _ => {}
376            }
377            if spawn_clipboard_command(cmd, text) {
378                trace_clipboard(&format!("native '{program}' succeeded ({} bytes)", text.len()));
379                return true;
380            }
381        }
382        trace_clipboard("all native clipboard candidates failed or missing");
383        false
384    }
385}
386
387/// Emits clipboard strategy diagnostics for support/debugging sessions.
388fn trace_clipboard(message: &str) {
389    tracing::debug!(target: "vtcode_ui::clipboard", "{message}");
390}
391
392/// Waits on `child` from a detached thread so no zombie accumulates and the caller never blocks.
393fn reap_in_background(mut child: std::process::Child) {
394    let _ = std::thread::spawn(move || {
395        let _ = child.wait();
396    });
397}
398
399fn spawn_clipboard_command(mut cmd: std::process::Command, text: &str) -> bool {
400    use std::process::Stdio;
401
402    // Helpers such as xclip/wl-copy normally exit right away, but some builds
403    // fork-and-hold while serving the selection, and a helper that never reads
404    // stdin could block a synchronous write_all forever once the pipe buffer
405    // fills (>= 64KB). The input is therefore written on a detached writer
406    // thread and the result is read via try_recv, so the UI thread never blocks
407    // longer than this budget regardless of helper behavior.
408    const WAIT_BUDGET: Duration = Duration::from_millis(300);
409    const POLL_INTERVAL: Duration = Duration::from_millis(10);
410    // After the helper exits, its writer report is usually already delivered;
411    // this grace window only covers the rare in-flight race.
412    const WRITE_REPORT_GRACE: Duration = Duration::from_millis(100);
413
414    let program = cmd.get_program().to_string_lossy().into_owned();
415    let Ok(mut child) = cmd.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else {
416        trace_clipboard(&format!("'{program}' unavailable: spawn failed"));
417        return false;
418    };
419    // The writer is detached and never joined so a blocked write_all cannot
420    // stall the UI thread; only the stdin pipe (plus a copy of the text) moves
421    // into it. `child` stays on this thread for try_wait polling and is handed
422    // to a detached reaper if the budget expires.
423    let mut stdin = child.stdin.take();
424    let text_owned = text.to_owned();
425    let (write_tx, write_rx) = std::sync::mpsc::channel();
426    let _ = std::thread::spawn(move || {
427        let result = match stdin.as_mut() {
428            Some(pipe) => pipe.write_all(text_owned.as_bytes()).err(),
429            None => None,
430        };
431        drop(stdin.take());
432        let _ = write_tx.send(result);
433    });
434    // Tracks the first observed write failure; None until the writer reports or
435    // a status/deadline path makes a decision.
436    let mut write_error = None;
437    let mut write_complete = false;
438    let deadline = Instant::now() + WAIT_BUDGET;
439    loop {
440        if let Ok(report) = write_rx.try_recv() {
441            write_error = report;
442            write_complete = true;
443        }
444        match child.try_wait() {
445            Ok(Some(status)) => {
446                // The helper has exited, so the writer will finish promptly;
447                // give its report a short grace window, otherwise a write
448                // failing concurrently with a clean exit would count as ok.
449                let flush_deadline = Instant::now() + WRITE_REPORT_GRACE;
450                while !write_complete {
451                    match write_rx.try_recv() {
452                        Ok(report) => {
453                            write_error = report;
454                            write_complete = true;
455                        }
456                        Err(std::sync::mpsc::TryRecvError::Empty) => {
457                            if Instant::now() >= flush_deadline {
458                                break;
459                            }
460                            std::thread::sleep(POLL_INTERVAL);
461                        }
462                        Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
463                    }
464                }
465                let ok = status.success() && write_error.is_none();
466                if !ok {
467                    if let Some(err) = write_error {
468                        trace_clipboard(&format!("'{program}' stdin write failed: {err}; status={status}"));
469                    } else {
470                        trace_clipboard(&format!("'{program}' failed: status={status}"));
471                    }
472                }
473                return ok;
474            }
475            Ok(None) => {
476                if Instant::now() >= deadline {
477                    // The writer may still be blocked; do NOT join it (that
478                    // would hang the UI thread). Input was at least accepted
479                    // into the pipe unless a write failure was already reported.
480                    let ok = write_error.is_none();
481                    trace_clipboard(&format!(
482                        "'{program}' still running after {WAIT_BUDGET:?}; input {}",
483                        if ok { "accepted" } else { "failed" }
484                    ));
485                    // Fork-and-hold helpers legitimately outlive the copy call.
486                    reap_in_background(child);
487                    return ok;
488                }
489                std::thread::sleep(POLL_INTERVAL);
490            }
491            Err(err) => {
492                trace_clipboard(&format!("'{program}' wait failed: {err}"));
493                reap_in_background(child);
494                return false;
495            }
496        }
497    }
498}
499
500/// Whether the session runs inside tmux; OSC 52 must then be DCS-wrapped or tmux
501/// consumes it without ever reaching the outer terminal.
502fn inside_tmux() -> bool {
503    std::env::var_os("TMUX").is_some_and(|value| !value.is_empty())
504}
505
506/// Wrap an escape sequence in tmux's DCS passthrough so tmux forwards it verbatim to
507/// the outer terminal. Every ESC inside the payload must be doubled.
508fn wrap_tmux_passthrough(sequence: &str) -> String {
509    format!("\x1bPtmux;\x1b{}\x1b\\", sequence.replace('\x1b', "\x1b\x1b"))
510}
511
512/// Build the OSC 52 payload for `text`, wrapped for tmux when requested.
513fn build_osc52_payload(text: &str, inside_tmux: bool) -> Option<String> {
514    let mut sequence = String::new();
515    CopyToClipboard::to_clipboard_from(text.as_bytes())
516        .write_ansi(&mut sequence)
517        .ok()?;
518    Some(if inside_tmux {
519        wrap_tmux_passthrough(&sequence)
520    } else {
521        sequence
522    })
523}
524
525/// Emit the OSC 52 clipboard sequence on stderr. Delivery cannot be confirmed —
526/// terminals never acknowledge it — so success means "written without IO error".
527fn copy_via_osc52(text: &str, inside_tmux: bool) -> bool {
528    #[cfg(test)]
529    if let Some(forced) = osc52_write_override() {
530        return forced;
531    }
532
533    let Some(payload) = build_osc52_payload(text, inside_tmux) else {
534        return false;
535    };
536
537    let mut stderr = std::io::stderr();
538    let written = stderr.write_all(payload.as_bytes()).is_ok() && stderr.flush().is_ok();
539    trace_clipboard(&format!("osc52 written={written} payload_bytes={} tmux_passthrough={inside_tmux}", payload.len()));
540    written
541}
542
543#[cfg(test)]
544static CLIPBOARD_COMMAND_OVERRIDE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
545
546#[cfg(test)]
547static OSC52_WRITE_OVERRIDE: OnceLock<Mutex<Option<bool>>> = OnceLock::new();
548
549#[cfg(test)]
550pub(crate) fn set_osc52_write_override(result: Option<bool>) {
551    let lock = OSC52_WRITE_OVERRIDE.get_or_init(|| Mutex::new(None));
552    if let Ok(mut guard) = lock.lock() {
553        *guard = result;
554    }
555}
556
557#[cfg(test)]
558fn osc52_write_override() -> Option<bool> {
559    let lock = OSC52_WRITE_OVERRIDE.get_or_init(|| Mutex::new(None));
560    lock.lock().ok().and_then(|guard| *guard)
561}
562
563#[cfg(test)]
564pub(crate) fn set_clipboard_command_override(path: Option<PathBuf>) {
565    let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
566    if let Ok(mut guard) = lock.lock() {
567        *guard = path;
568    }
569}
570
571#[cfg(test)]
572pub(crate) fn clipboard_command_override() -> Option<PathBuf> {
573    let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
574    match lock.lock() {
575        Ok(guard) => guard.clone(),
576        Err(_) => None,
577    }
578}
579
580/// Return the half-open display-column range for the word under `column`.
581pub(crate) fn word_selection_range(text: &str, column: u16) -> Option<(u16, u16)> {
582    if text.is_empty() {
583        return None;
584    }
585
586    let chars: Vec<char> = text.chars().collect();
587    if chars.is_empty() {
588        return None;
589    }
590
591    let line_width = UnicodeWidthStr::width(text);
592    if usize::from(column) >= line_width {
593        return None;
594    }
595
596    let mut consumed = 0usize;
597    let mut char_index = 0usize;
598    for ch in &chars {
599        let width = UnicodeWidthChar::width(*ch).unwrap_or(0);
600        if consumed.saturating_add(width) > usize::from(column) {
601            break;
602        }
603        consumed = consumed.saturating_add(width);
604        char_index += 1;
605    }
606
607    if char_index >= chars.len() || chars[char_index].is_whitespace() {
608        return None;
609    }
610
611    let mut start = char_index;
612    while start > 0 && !chars[start - 1].is_whitespace() {
613        start -= 1;
614    }
615
616    let mut end = char_index + 1;
617    while end < chars.len() && !chars[end].is_whitespace() {
618        end += 1;
619    }
620
621    Some((display_width_for_char_count(&chars, start), display_width_for_char_count(&chars, end)))
622}
623
624fn display_width_for_char_count(chars: &[char], char_count: usize) -> u16 {
625    chars
626        .iter()
627        .take(char_count)
628        .map(|ch| UnicodeWidthChar::width(*ch).unwrap_or(0) as u16)
629        .fold(0_u16, u16::saturating_add)
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use ratatui::style::Color;
636    use std::time::{Duration, Instant};
637
638    #[test]
639    fn extract_text_clamps_area_to_buffer_bounds() {
640        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
641        buf[(0, 0)].set_symbol("A");
642        buf[(1, 0)].set_symbol("B");
643        buf[(0, 1)].set_symbol("C");
644        buf[(1, 1)].set_symbol("D");
645
646        let mut selection = MouseSelectionState::new();
647        selection.start_selection(0, 0);
648        selection.finish_selection(5, 5);
649
650        let text = selection.extract_text(&buf, Rect::new(0, 0, 10, 10));
651        assert_eq!(text, "AB\nCD");
652    }
653
654    #[test]
655    fn apply_highlight_clamps_area_to_buffer_bounds() {
656        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
657        buf[(0, 0)].set_fg(Color::Red);
658        buf[(0, 0)].set_bg(Color::Blue);
659
660        let mut selection = MouseSelectionState::new();
661        selection.start_selection(0, 0);
662        selection.finish_selection(5, 5);
663
664        selection.apply_highlight(&mut buf, Rect::new(0, 0, 10, 10));
665
666        assert_eq!(buf[(0, 0)].fg, Color::Blue);
667        assert_eq!(buf[(0, 0)].bg, Color::Red);
668    }
669
670    #[test]
671    fn word_selection_range_selects_clicked_word() {
672        assert_eq!(word_selection_range("hello world", 1), Some((0, 5)));
673        assert_eq!(word_selection_range("hello world", 7), Some((6, 11)));
674    }
675
676    #[test]
677    fn word_selection_range_returns_none_for_whitespace() {
678        assert_eq!(word_selection_range("hello world", 5), None);
679    }
680
681    #[test]
682    fn adjust_for_scroll_shifts_rows() {
683        let mut sel = MouseSelectionState::new();
684        sel.set_selection((2, 5), (10, 8));
685
686        sel.adjust_for_scroll(3);
687        assert_eq!(sel.start, (2, 8));
688        assert_eq!(sel.end, (10, 11));
689        assert!(sel.has_selection);
690    }
691
692    #[test]
693    fn adjust_for_scroll_negative() {
694        let mut sel = MouseSelectionState::new();
695        sel.set_selection((0, 10), (5, 15));
696
697        sel.adjust_for_scroll(-4);
698        assert_eq!(sel.start, (0, 6));
699        assert_eq!(sel.end, (5, 11));
700    }
701
702    #[test]
703    fn adjust_for_scroll_clears_when_offscreen() {
704        let mut sel = MouseSelectionState::new();
705        sel.set_selection((0, 2), (5, 4));
706
707        sel.adjust_for_scroll(-10);
708        assert!(!sel.has_selection);
709        assert!(!sel.is_selecting);
710    }
711
712    #[test]
713    fn adjust_for_scroll_noop_without_selection() {
714        let mut sel = MouseSelectionState::new();
715        sel.adjust_for_scroll(5);
716        assert!(!sel.has_selection);
717    }
718
719    #[test]
720    fn register_click_detects_double_clicks_at_same_position() {
721        let mut selection = MouseSelectionState::new();
722        let now = Instant::now();
723
724        assert!(!selection.register_click(3, 7, now));
725        assert!(selection.register_click(3, 7, now + Duration::from_millis(250)));
726        assert!(!selection.register_click(4, 7, now + Duration::from_millis(250)));
727    }
728
729    #[test]
730    fn spawn_clipboard_command_requires_success_exit_status() {
731        let failing = {
732            let mut cmd = std::process::Command::new("sh");
733            cmd.arg("-c").arg("exit 1");
734            cmd
735        };
736        assert!(!spawn_clipboard_command(failing, "hello"));
737
738        let succeeding = {
739            let mut cmd = std::process::Command::new("sh");
740            cmd.arg("-c").arg("cat > /dev/null");
741            cmd
742        };
743        assert!(spawn_clipboard_command(succeeding, "hello"));
744    }
745
746    #[test]
747    fn spawn_clipboard_command_treats_hanging_helper_as_success() {
748        // A helper that holds stdin open and never exits must not block the
749        // UI thread forever: the bounded wait accepts the already-written
750        // input and returns without hanging.
751        let hanging = {
752            let mut cmd = std::process::Command::new("sh");
753            cmd.arg("-c").arg("cat > /dev/null & sleep 10");
754            cmd
755        };
756        let start = Instant::now();
757        assert!(spawn_clipboard_command(hanging, "hello"));
758        assert!(
759            start.elapsed() < Duration::from_secs(2),
760            "bounded wait must return promptly, took {:?}",
761            start.elapsed()
762        );
763    }
764
765    #[test]
766    fn spawn_clipboard_command_large_write_to_non_reading_helper_returns_promptly() {
767        // A helper that never reads stdin would block a synchronous write_all
768        // forever once the 64KB pipe buffer fills. The detached writer must not
769        // stall the caller: the bounded poll returns without joining it.
770        let non_reading = {
771            let mut cmd = std::process::Command::new("sh");
772            cmd.arg("-c").arg("sleep 10");
773            cmd
774        };
775        let big: String = "x".repeat(128 * 1024);
776        let start = Instant::now();
777        let result = spawn_clipboard_command(non_reading, &big);
778        assert!(start.elapsed() < Duration::from_secs(2), "must not block on the writer, took {:?}", start.elapsed());
779        // No write failure was observed within the budget, so the input is
780        // treated as accepted even though the helper never reads.
781        assert!(result);
782    }
783
784    #[test]
785    fn build_osc52_payload_emits_base64_clipboard_sequence() {
786        let payload = build_osc52_payload("hi", false).expect("osc52 payload");
787        assert!(payload.starts_with("\x1b]52;c;"), "unexpected payload: {payload:?}");
788        assert!(payload.ends_with("\x1b\\"));
789        assert!(payload.contains("aGk="));
790    }
791
792    #[test]
793    fn build_osc52_payload_wraps_in_tmux_passthrough() {
794        let plain = build_osc52_payload("hi", false).expect("plain payload");
795        let wrapped = build_osc52_payload("hi", true).expect("wrapped payload");
796
797        // DCS introducer + doubled-ESC payload + ST terminator.
798        assert_eq!(wrapped, format!("\x1bPtmux;\x1b{}\x1b\\", plain.replace('\x1b', "\x1b\x1b")));
799        assert!(wrapped.starts_with("\x1bPtmux;\x1b\x1b\x1b]52;c;"));
800        assert!(wrapped.ends_with("\x1b\x1b\\\x1b\\"));
801    }
802
803    #[test]
804    fn wrap_tmux_passthrough_doubles_every_escape() {
805        assert_eq!(wrap_tmux_passthrough("\x1ba\x1bb"), "\x1bPtmux;\x1b\x1b\x1ba\x1b\x1bb\x1b\\");
806    }
807}