use ratatui::buffer::Buffer;
use ratatui::crossterm::{clipboard::CopyToClipboard, execute};
use ratatui::layout::Rect;
use std::io::Write;
#[cfg(test)]
use std::path::PathBuf;
#[cfg(test)]
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(450);
#[derive(Debug, Default)]
pub struct MouseSelectionState {
pub is_selecting: bool,
pub start: (u16, u16),
pub end: (u16, u16),
pub has_selection: bool,
copied: bool,
copy_requested: bool,
last_click: Option<ClickRecord>,
}
#[derive(Clone, Copy, Debug)]
struct ClickRecord {
column: u16,
row: u16,
at: Instant,
}
impl MouseSelectionState {
pub fn new() -> Self {
Self::default()
}
pub fn start_selection(&mut self, col: u16, row: u16) {
self.is_selecting = true;
self.has_selection = false;
self.copied = false;
self.start = (col, row);
self.end = (col, row);
}
pub fn set_selection(&mut self, start: (u16, u16), end: (u16, u16)) {
self.is_selecting = false;
self.has_selection = start != end;
self.copied = false;
self.start = start;
self.end = end;
}
pub fn update_selection(&mut self, col: u16, row: u16) {
if self.is_selecting {
self.end = (col, row);
self.has_selection = true;
}
}
pub fn finish_selection(&mut self, col: u16, row: u16) {
if self.is_selecting {
self.end = (col, row);
self.is_selecting = false;
self.has_selection = self.start != self.end;
}
}
#[allow(dead_code)]
pub fn clear(&mut self) {
self.is_selecting = false;
self.has_selection = false;
self.copied = false;
self.copy_requested = false;
self.last_click = None;
}
pub fn clear_click_history(&mut self) {
self.last_click = None;
}
pub fn register_click(&mut self, col: u16, row: u16, at: Instant) -> bool {
let is_double_click = self.last_click.is_some_and(|last| {
last.column == col
&& last.row == row
&& at.saturating_duration_since(last.at) <= DOUBLE_CLICK_INTERVAL
});
self.last_click = Some(ClickRecord {
column: col,
row,
at,
});
is_double_click
}
fn normalized(&self) -> ((u16, u16), (u16, u16)) {
let (s, e) = (self.start, self.end);
if s.1 < e.1 || (s.1 == e.1 && s.0 <= e.0) {
(s, e)
} else {
(e, s)
}
}
pub fn extract_text(&self, buf: &Buffer, area: Rect) -> String {
if !self.has_selection && !self.is_selecting {
return String::new();
}
let area = area.intersection(buf.area);
if area.width == 0 || area.height == 0 {
return String::new();
}
let ((start_col, start_row), (end_col, end_row)) = self.normalized();
let mut result = String::new();
for row in start_row..=end_row {
if row < area.y || row >= area.bottom() {
continue;
}
let line_start = if row == start_row {
start_col.max(area.x)
} else {
area.x
};
let line_end = if row == end_row {
end_col.min(area.right())
} else {
area.right()
};
for col in line_start..line_end {
if col < area.x || col >= area.right() {
continue;
}
let cell = &buf[(col, row)];
let symbol = cell.symbol();
if !symbol.is_empty() {
result.push_str(symbol);
}
}
if row < end_row {
let trimmed = result.trim_end().len();
result.truncate(trimmed);
result.push('\n');
}
}
let trimmed = result.trim_end();
trimmed.to_string()
}
pub fn apply_highlight(&self, buf: &mut Buffer, area: Rect) {
if !self.has_selection && !self.is_selecting {
return;
}
let area = area.intersection(buf.area);
if area.width == 0 || area.height == 0 {
return;
}
let ((start_col, start_row), (end_col, end_row)) = self.normalized();
for row in start_row..=end_row {
if row < area.y || row >= area.bottom() {
continue;
}
let line_start = if row == start_row {
start_col.max(area.x)
} else {
area.x
};
let line_end = if row == end_row {
end_col.min(area.right())
} else {
area.right()
};
for col in line_start..line_end {
if col < area.x || col >= area.right() {
continue;
}
let cell = &mut buf[(col, row)];
let fg = cell.fg;
let bg = cell.bg;
cell.set_fg(bg);
cell.set_bg(fg);
}
}
}
pub fn needs_copy(&self) -> bool {
self.has_selection && !self.is_selecting && !self.copied
}
pub fn has_copy_request(&self) -> bool {
self.copy_requested
}
pub fn request_copy(&mut self) {
if self.has_selection {
self.copy_requested = true;
}
}
pub fn mark_copied(&mut self) {
self.copied = true;
}
pub fn copy_to_clipboard(text: &str) {
if text.is_empty() {
return;
}
if Self::copy_via_native(text) {
return;
}
let _ = execute!(
std::io::stderr(),
CopyToClipboard::to_clipboard_from(text.as_bytes())
);
let _ = std::io::stderr().flush();
}
fn copy_via_native(text: &str) -> bool {
use std::process::Command;
#[cfg(test)]
if let Some(program) = clipboard_command_override() {
return spawn_clipboard_command(Command::new(program), text);
}
let candidates: &[&str] = if cfg!(target_os = "macos") {
&["pbcopy"]
} else if cfg!(target_os = "linux") {
&["xclip", "xsel"]
} else if cfg!(target_os = "windows") {
&["clip.exe"]
} else {
&[]
};
for program in candidates {
let mut cmd = Command::new(program);
match *program {
"xclip" => {
cmd.arg("-selection").arg("clipboard");
}
"xsel" => {
cmd.arg("--clipboard").arg("--input");
}
_ => {}
}
if spawn_clipboard_command(cmd, text) {
return true;
}
}
false
}
}
fn spawn_clipboard_command(mut cmd: std::process::Command, text: &str) -> bool {
use std::process::Stdio;
let Ok(mut child) = cmd
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
return false;
};
if let Some(stdin) = child.stdin.as_mut() {
let _ = stdin.write_all(text.as_bytes());
}
drop(child.stdin.take());
child.wait().is_ok()
}
#[cfg(test)]
static CLIPBOARD_COMMAND_OVERRIDE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
#[cfg(test)]
pub(crate) fn set_clipboard_command_override(path: Option<PathBuf>) {
let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
if let Ok(mut guard) = lock.lock() {
*guard = path;
}
}
#[cfg(test)]
pub(crate) fn clipboard_command_override() -> Option<PathBuf> {
let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
match lock.lock() {
Ok(guard) => guard.clone(),
Err(_) => None,
}
}
pub(crate) fn word_selection_range(text: &str, column: u16) -> Option<(u16, u16)> {
if text.is_empty() {
return None;
}
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return None;
}
let line_width = UnicodeWidthStr::width(text);
if usize::from(column) >= line_width {
return None;
}
let mut consumed = 0usize;
let mut char_index = 0usize;
for ch in &chars {
let width = UnicodeWidthChar::width(*ch).unwrap_or(0);
if consumed.saturating_add(width) > usize::from(column) {
break;
}
consumed = consumed.saturating_add(width);
char_index += 1;
}
if char_index >= chars.len() || chars[char_index].is_whitespace() {
return None;
}
let mut start = char_index;
while start > 0 && !chars[start - 1].is_whitespace() {
start -= 1;
}
let mut end = char_index + 1;
while end < chars.len() && !chars[end].is_whitespace() {
end += 1;
}
Some((
display_width_for_char_count(&chars, start),
display_width_for_char_count(&chars, end),
))
}
fn display_width_for_char_count(chars: &[char], char_count: usize) -> u16 {
chars
.iter()
.take(char_count)
.map(|ch| UnicodeWidthChar::width(*ch).unwrap_or(0) as u16)
.fold(0_u16, u16::saturating_add)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
use std::time::{Duration, Instant};
#[test]
fn extract_text_clamps_area_to_buffer_bounds() {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
buf[(0, 0)].set_symbol("A");
buf[(1, 0)].set_symbol("B");
buf[(0, 1)].set_symbol("C");
buf[(1, 1)].set_symbol("D");
let mut selection = MouseSelectionState::new();
selection.start_selection(0, 0);
selection.finish_selection(5, 5);
let text = selection.extract_text(&buf, Rect::new(0, 0, 10, 10));
assert_eq!(text, "AB\nCD");
}
#[test]
fn apply_highlight_clamps_area_to_buffer_bounds() {
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
buf[(0, 0)].set_fg(Color::Red);
buf[(0, 0)].set_bg(Color::Blue);
let mut selection = MouseSelectionState::new();
selection.start_selection(0, 0);
selection.finish_selection(5, 5);
selection.apply_highlight(&mut buf, Rect::new(0, 0, 10, 10));
assert_eq!(buf[(0, 0)].fg, Color::Blue);
assert_eq!(buf[(0, 0)].bg, Color::Red);
}
#[test]
fn word_selection_range_selects_clicked_word() {
assert_eq!(word_selection_range("hello world", 1), Some((0, 5)));
assert_eq!(word_selection_range("hello world", 7), Some((6, 11)));
}
#[test]
fn word_selection_range_returns_none_for_whitespace() {
assert_eq!(word_selection_range("hello world", 5), None);
}
#[test]
fn register_click_detects_double_clicks_at_same_position() {
let mut selection = MouseSelectionState::new();
let now = Instant::now();
assert!(!selection.register_click(3, 7, now));
assert!(selection.register_click(3, 7, now + Duration::from_millis(250)));
assert!(!selection.register_click(4, 7, now + Duration::from_millis(250)));
}
}