use std::time::Instant;
use anyhow::Result;
use arboard::Clipboard;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::{App, DisplayableItem};
use crate::diff::LineSource;
use crate::patch;
use crate::ui::{ScreenRowInfo, PREFIX_CHAR_WIDTH};
pub(crate) const MULTI_CLICK_MS: u128 = 500;
fn byte_at_display_col(s: &str, col: usize) -> usize {
let mut w = 0;
for (i, ch) in s.char_indices() {
if w >= col {
return i;
}
w += UnicodeWidthChar::width(ch).unwrap_or(0);
}
s.len()
}
fn display_slice(s: &str, start: usize, end: usize) -> &str {
let start_byte = byte_at_display_col(s, start);
let end_byte = byte_at_display_col(s, end);
&s[start_byte..end_byte]
}
fn display_slice_from(s: &str, start: usize) -> &str {
&s[byte_at_display_col(s, start)..]
}
fn display_slice_to(s: &str, end: usize) -> &str {
&s[..byte_at_display_col(s, end)]
}
fn display_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
fn display_col_to_char_idx(chars: &[char], col: usize) -> usize {
let mut w = 0;
for (idx, &ch) in chars.iter().enumerate() {
if w >= col {
return idx;
}
w += UnicodeWidthChar::width(ch).unwrap_or(0);
}
chars.len()
}
fn char_idx_to_display_col(chars: &[char], idx: usize) -> usize {
chars[..idx]
.iter()
.map(|c| UnicodeWidthChar::width(*c).unwrap_or(0))
.sum()
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn is_symbol_char(c: char) -> bool {
!is_word_char(c) && !c.is_whitespace()
}
fn find_selection_boundaries(s: &str, col: usize) -> Option<(usize, usize)> {
let chars: Vec<char> = s.chars().collect();
let char_idx = display_col_to_char_idx(&chars, col);
if char_idx >= chars.len() {
return None;
}
let c = chars[char_idx];
let (start_char, end_char) = if is_word_char(c) {
find_word_boundaries_impl(&chars, char_idx)?
} else if is_symbol_char(c) {
find_symbol_boundaries_impl(&chars, char_idx)?
} else {
let mut scan = char_idx;
while scan < chars.len() && chars[scan].is_whitespace() {
scan += 1;
}
if scan >= chars.len() {
return None;
}
if is_word_char(chars[scan]) {
find_word_boundaries_impl(&chars, scan)?
} else {
find_symbol_boundaries_impl(&chars, scan)?
}
};
Some((
char_idx_to_display_col(&chars, start_char),
char_idx_to_display_col(&chars, end_char),
))
}
fn find_word_boundaries_impl(chars: &[char], col: usize) -> Option<(usize, usize)> {
if col >= chars.len() || !is_word_char(chars[col]) {
return None;
}
let mut start = col;
while start > 0 && is_word_char(chars[start - 1]) {
start -= 1;
}
let mut end = col;
while end < chars.len() && is_word_char(chars[end]) {
end += 1;
}
Some((start, end))
}
fn find_symbol_boundaries_impl(chars: &[char], col: usize) -> Option<(usize, usize)> {
if col >= chars.len() || !is_symbol_char(chars[col]) {
return None;
}
let mut start = col;
while start > 0 && is_symbol_char(chars[start - 1]) {
start -= 1;
}
let mut end = col;
while end < chars.len() && is_symbol_char(chars[end]) {
end += 1;
}
Some((start, end))
}
#[cfg(test)]
fn find_word_boundaries(s: &str, col: usize) -> Option<(usize, usize)> {
let chars: Vec<char> = s.chars().collect();
find_word_boundaries_impl(&chars, col)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Position {
pub row: usize,
pub col: usize,
}
#[derive(Debug, Clone)]
pub struct Selection {
pub start: Position,
pub end: Position,
pub active: bool,
}
impl App {
fn prefix_len(&self) -> usize {
if self.view.line_num_width > 0 {
self.view.line_num_width + 1 + PREFIX_CHAR_WIDTH
} else {
PREFIX_CHAR_WIDTH
}
}
pub fn set_row_map(&mut self, row_map: Vec<ScreenRowInfo>) {
self.view.row_map = row_map;
}
pub fn start_selection(&mut self, screen_x: u16, screen_y: u16) {
self.view.word_selection_anchor = None;
self.view.line_selection_anchor = None;
if let Some(pos) = self.screen_to_content_position(screen_x, screen_y) {
self.view.selection = Some(Selection {
start: pos,
end: pos,
active: true,
});
}
}
pub fn update_selection(&mut self, screen_x: u16, screen_y: u16) {
let pos = match self.screen_to_content_position(screen_x, screen_y) {
Some(p) => p,
None => return,
};
let is_active = self.view.selection.as_ref().is_some_and(|s| s.active);
if !is_active {
return;
}
let prefix_len = self.prefix_len();
if let Some((anchor_start_row, anchor_end_row)) = self.view.line_selection_anchor {
let (drag_start_row, drag_end_row) = self.find_logical_line_bounds(pos.row);
let (new_start, new_end) = if pos.row < anchor_start_row {
let end_content_len = self.view.row_map.get(anchor_end_row)
.map(|r| display_width(&r.content))
.unwrap_or(0);
(
Position { row: drag_start_row, col: prefix_len },
Position { row: anchor_end_row, col: end_content_len + prefix_len },
)
} else {
let end_content_len = self.view.row_map.get(drag_end_row)
.map(|r| display_width(&r.content))
.unwrap_or(0);
(
Position { row: anchor_start_row, col: prefix_len },
Position { row: drag_end_row, col: end_content_len + prefix_len },
)
};
if let Some(ref mut sel) = self.view.selection {
sel.start = new_start;
sel.end = new_end;
}
return;
}
if let Some((anchor_row, anchor_start, anchor_end)) = self.view.word_selection_anchor {
let (drag_start, drag_end) = if pos.row < self.view.row_map.len() {
let content = &self.view.row_map[pos.row].content;
let content_col = pos.col.saturating_sub(prefix_len);
find_selection_boundaries(content, content_col)
.map(|(s, e)| (s + prefix_len, e + prefix_len))
.unwrap_or((pos.col, pos.col))
} else {
(pos.col, pos.col)
};
let (new_start, new_end) =
if pos.row < anchor_row || (pos.row == anchor_row && drag_start < anchor_start) {
(
Position { row: pos.row, col: drag_start },
Position { row: anchor_row, col: anchor_end },
)
} else {
(
Position { row: anchor_row, col: anchor_start },
Position { row: pos.row, col: drag_end },
)
};
if let Some(ref mut sel) = self.view.selection {
sel.start = new_start;
sel.end = new_end;
}
} else {
if let Some(ref mut sel) = self.view.selection {
sel.end = pos;
}
}
}
pub fn end_selection(&mut self) {
self.view.word_selection_anchor = None;
self.view.line_selection_anchor = None;
if let Some(ref mut sel) = self.view.selection {
sel.active = false;
}
}
pub fn end_selection_with_auto_copy(&mut self) {
let click_info = self.view.last_click;
self.end_selection();
match click_info {
None => {
if self.has_non_empty_selection() {
let _ = self.copy_selection();
}
}
Some((_, _, _, count)) if count >= 2 => {
if self.has_non_empty_selection() {
self.view.pending_copy = Some(Instant::now());
}
}
_ => {
}
}
}
pub fn cancel_pending_copy(&mut self) {
self.view.pending_copy = None;
}
pub fn check_and_execute_pending_copy(&mut self) -> bool {
if let Some(pending_time) = self.view.pending_copy
&& pending_time.elapsed().as_millis() >= MULTI_CLICK_MS
{
self.view.pending_copy = None;
let _ = self.copy_selection();
return true;
}
false
}
pub fn has_non_empty_selection(&self) -> bool {
self.view.selection.as_ref().is_some_and(|sel| {
sel.start.row != sel.end.row || sel.start.col != sel.end.col
})
}
pub fn select_word_at(&mut self, screen_x: u16, screen_y: u16) {
let pos = match self.screen_to_content_position(screen_x, screen_y) {
Some(p) => p,
None => return,
};
let is_status_bar = pos.row >= self.view.row_map.len();
let (content, prefix_len) = match self.row_content(pos.row) {
Some(r) => (r.0.to_string(), r.1),
None => return,
};
let content_col = pos.col.saturating_sub(prefix_len);
let content_len = display_width(&content);
if content_col >= content_len {
if is_status_bar {
self.select_status_bar_line(pos.row, &content);
} else {
self.select_logical_line(pos.row, prefix_len);
}
} else if let Some((start, end)) = find_selection_boundaries(&content, content_col) {
let sel_start = start + prefix_len;
let sel_end = end + prefix_len;
self.view.word_selection_anchor = Some((pos.row, sel_start, sel_end));
self.view.selection = Some(Selection {
start: Position {
row: pos.row,
col: sel_start,
},
end: Position {
row: pos.row,
col: sel_end,
},
active: true, });
}
}
pub fn select_line_at(&mut self, screen_x: u16, screen_y: u16) {
let pos = match self.screen_to_content_position(screen_x, screen_y) {
Some(p) => p,
None => return,
};
let is_status_bar = pos.row >= self.view.row_map.len();
if is_status_bar {
if let Some((content, _)) = self.row_content(pos.row) {
let content = content.to_string();
self.select_status_bar_line(pos.row, &content);
}
return;
}
let prefix_len = self.prefix_len();
let (start_row, end_row) = self.find_logical_line_bounds(pos.row);
self.view.line_selection_anchor = Some((start_row, end_row));
let end_content_len = display_width(&self.view.row_map[end_row].content);
self.view.selection = Some(Selection {
start: Position { row: start_row, col: prefix_len },
end: Position { row: end_row, col: end_content_len + prefix_len },
active: true,
});
}
fn select_status_bar_line(&mut self, row: usize, content: &str) {
let content_len = display_width(content);
self.view.line_selection_anchor = Some((row, row));
self.view.word_selection_anchor = None;
self.view.selection = Some(Selection {
start: Position { row, col: 0 },
end: Position { row, col: content_len },
active: true,
});
}
fn find_logical_line_bounds(&self, screen_row: usize) -> (usize, usize) {
if screen_row >= self.view.row_map.len() {
return (screen_row, screen_row);
}
let mut start_row = screen_row;
while start_row > 0 && self.view.row_map[start_row].is_continuation {
start_row -= 1;
}
let mut end_row = screen_row;
while end_row + 1 < self.view.row_map.len() && self.view.row_map[end_row + 1].is_continuation {
end_row += 1;
}
(start_row, end_row)
}
fn select_logical_line(&mut self, screen_row: usize, prefix_len: usize) {
let mut start_row = screen_row;
while start_row > 0 && self.view.row_map[start_row].is_continuation {
start_row -= 1;
}
let mut end_row = screen_row;
while end_row + 1 < self.view.row_map.len() && self.view.row_map[end_row + 1].is_continuation {
end_row += 1;
}
let end_content_len = display_width(&self.view.row_map[end_row].content);
let sel_start = prefix_len;
let sel_end = end_content_len + prefix_len;
self.view.word_selection_anchor = Some((start_row, sel_start, sel_end));
self.view.selection = Some(Selection {
start: Position {
row: start_row,
col: sel_start,
},
end: Position {
row: end_row,
col: sel_end,
},
active: true,
});
}
pub fn clear_selection(&mut self) {
self.view.selection = None;
self.view.word_selection_anchor = None;
self.view.line_selection_anchor = None;
}
pub fn has_selection(&self) -> bool {
self.view.selection.is_some()
}
fn screen_to_content_position(&self, screen_x: u16, screen_y: u16) -> Option<Position> {
let (offset_x, offset_y) = self.view.content_offset;
let sb_y = self.view.status_bar_screen_y;
let sb_lines = &self.view.status_bar_lines;
if !sb_lines.is_empty()
&& sb_y > 0
&& screen_y >= sb_y
&& (screen_y - sb_y) < sb_lines.len() as u16
{
let virtual_row = self.view.row_map.len() + (screen_y - sb_y) as usize;
return Some(Position {
row: virtual_row,
col: screen_x as usize,
});
}
if screen_x >= offset_x && screen_y >= offset_y {
let content_x = (screen_x - offset_x) as usize;
let content_y = (screen_y - offset_y) as usize;
return Some(Position {
row: content_y,
col: content_x,
});
}
None
}
fn row_content(&self, screen_row: usize) -> Option<(&str, usize)> {
let row_map_len = self.view.row_map.len();
if screen_row < row_map_len {
Some((&self.view.row_map[screen_row].content, self.prefix_len()))
} else {
let sb_idx = screen_row - row_map_len;
self.view.status_bar_lines.get(sb_idx).map(|s| (s.as_str(), 0))
}
}
fn is_next_row_continuation(&self, screen_row: usize) -> bool {
let next = screen_row + 1;
next < self.view.row_map.len()
&& self.view.row_map[next].is_continuation
}
pub fn get_selected_text(&self) -> Option<String> {
let sel = self.view.selection.as_ref()?;
let (start, end) = if sel.start.row < sel.end.row
|| (sel.start.row == sel.end.row && sel.start.col <= sel.end.col)
{
(sel.start, sel.end)
} else {
(sel.end, sel.start)
};
let mut result = String::new();
for screen_row in start.row..=end.row {
let (content, prefix_len) = match self.row_content(screen_row) {
Some(r) => r,
None => break,
};
let content_width = display_width(content);
if start.row == end.row {
let start_in_content = start.col.saturating_sub(prefix_len);
let end_in_content = end.col.saturating_sub(prefix_len);
if start_in_content < content_width {
let actual_end = end_in_content.min(content_width);
if actual_end > start_in_content {
result.push_str(display_slice(content, start_in_content, actual_end));
}
}
} else if screen_row == start.row {
let start_in_content = start.col.saturating_sub(prefix_len);
if start_in_content < content_width {
result.push_str(display_slice_from(content, start_in_content));
}
if !self.is_next_row_continuation(screen_row) {
result.push('\n');
}
} else if screen_row == end.row {
let end_in_content = end.col.saturating_sub(prefix_len);
let actual_end = end_in_content.min(content_width);
result.push_str(display_slice_to(content, actual_end));
} else {
result.push_str(content);
if !self.is_next_row_continuation(screen_row) {
result.push('\n');
}
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
pub fn copy_selection(&mut self) -> Result<bool> {
if let Some(text) = self.get_selected_text() {
let mut clipboard = Clipboard::new()
.map_err(|e| anyhow::anyhow!("Failed to access clipboard: {}", e))?;
clipboard.set_text(text)
.map_err(|e| anyhow::anyhow!("Failed to copy to clipboard: {}", e))?;
self.clear_selection();
Ok(true)
} else {
Ok(false)
}
}
pub fn copy_current_path(&mut self) -> Result<bool> {
if let Some(path) = self.current_file() {
let mut clipboard = Clipboard::new()
.map_err(|e| anyhow::anyhow!("Failed to access clipboard: {}", e))?;
clipboard.set_text(path)
.map_err(|e| anyhow::anyhow!("Failed to copy to clipboard: {}", e))?;
self.view.path_copied_at = Some(std::time::Instant::now());
Ok(true)
} else {
Ok(false)
}
}
pub fn copy_diff(&mut self) -> Result<bool> {
let text = self.format_diff_for_copy();
if text.is_empty() {
return Ok(false);
}
let mut clipboard = Clipboard::new()
.map_err(|e| anyhow::anyhow!("Failed to access clipboard: {}", e))?;
clipboard.set_text(text)
.map_err(|e| anyhow::anyhow!("Failed to copy to clipboard: {}", e))?;
self.view.path_copied_at = Some(std::time::Instant::now());
Ok(true)
}
pub fn copy_patch(&mut self) -> Result<bool> {
let text = patch::generate_patch(&self.lines);
if text.is_empty() {
return Ok(false);
}
let mut clipboard = Clipboard::new()
.map_err(|e| anyhow::anyhow!("Failed to access clipboard: {}", e))?;
clipboard
.set_text(text)
.map_err(|e| anyhow::anyhow!("Failed to copy to clipboard: {}", e))?;
self.view.path_copied_at = Some(std::time::Instant::now());
Ok(true)
}
pub(crate) fn format_diff_for_copy(&self) -> String {
let items = self.compute_displayable_items();
if items.is_empty() {
return String::new();
}
let max_line_num = items
.iter()
.filter_map(|item| {
if let DisplayableItem::Line(idx) = item {
self.lines[*idx].line_number
} else {
None
}
})
.max()
.unwrap_or(0);
let line_num_width = if max_line_num > 0 {
max_line_num.to_string().len()
} else {
0
};
let mut result = String::new();
for item in &items {
match item {
DisplayableItem::Elided(count) => {
let padding = if line_num_width > 0 {
" ".repeat(line_num_width + 1)
} else {
String::new()
};
result.push_str(&format!("{}... {} lines hidden ...\n", padding, count));
}
DisplayableItem::Message(msg) => {
result.push_str(&format!("{}\n", msg));
}
DisplayableItem::Line(idx) => {
let line = &self.lines[*idx];
let line_num_str = if let Some(num) = line.line_number {
format!("{:>width$} ", num, width = line_num_width)
} else if line_num_width > 0 {
" ".repeat(line_num_width + 1)
} else {
String::new()
};
if line.source == LineSource::FileHeader {
result.push_str(&format!("{}── {} ──\n", line_num_str, line.content));
} else {
result.push_str(&format!(
"{}{} {}\n",
line_num_str, line.prefix, line.content
));
}
}
}
}
result
}
pub fn should_show_copied_flash(&self) -> bool {
if let Some(copied_at) = self.view.path_copied_at {
copied_at.elapsed() < std::time::Duration::from_millis(800)
} else {
false
}
}
pub fn get_file_header_at(&self, screen_x: u16, screen_y: u16) -> Option<String> {
let (offset_x, offset_y) = self.view.content_offset;
if screen_x < offset_x || screen_y < offset_y {
return None;
}
let content_y = (screen_y - offset_y) as usize;
if content_y < self.view.row_map.len() {
let row_info = &self.view.row_map[content_y];
if row_info.is_file_header {
return row_info.file_path.clone();
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::TestAppBuilder;
use crate::ui::ScreenRowInfo;
fn make_row(content: &str, is_continuation: bool) -> ScreenRowInfo {
ScreenRowInfo {
content: content.to_string(),
is_file_header: false,
file_path: None,
is_continuation,
}
}
#[test]
fn test_get_selected_text_unwrapped_lines() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.row_map = vec![
make_row("line one", false),
make_row("line two", false),
];
app.view.selection = Some(Selection {
start: Position { row: 0, col: 8 }, end: Position { row: 1, col: 16 }, active: false,
});
let text = app.get_selected_text().unwrap();
assert_eq!(text, "line one\nline two");
}
#[test]
fn test_get_selected_text_wrapped_line_no_extra_newlines() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.row_map = vec![
make_row("first part ", false), make_row("second part", true), ];
app.view.selection = Some(Selection {
start: Position { row: 0, col: 8 },
end: Position { row: 1, col: 19 }, active: false,
});
let text = app.get_selected_text().unwrap();
assert_eq!(text, "first part second part");
}
#[test]
fn test_get_selected_text_mixed_wrapped_and_unwrapped() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.row_map = vec![
make_row("wrapped ", false), make_row("line", true), make_row("normal line", false), ];
app.view.selection = Some(Selection {
start: Position { row: 0, col: 8 },
end: Position { row: 2, col: 19 }, active: false,
});
let text = app.get_selected_text().unwrap();
assert_eq!(text, "wrapped line\nnormal line");
}
#[test]
fn test_get_selected_text_starting_on_continuation() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.row_map = vec![
make_row("first ", false), make_row("second", true), make_row("next line", false), ];
app.view.selection = Some(Selection {
start: Position { row: 1, col: 8 },
end: Position { row: 2, col: 17 }, active: false,
});
let text = app.get_selected_text().unwrap();
assert_eq!(text, "second\nnext line");
}
#[test]
fn test_find_word_boundaries_simple() {
assert_eq!(find_word_boundaries("hello world", 0), Some((0, 5)));
assert_eq!(find_word_boundaries("hello world", 2), Some((0, 5)));
assert_eq!(find_word_boundaries("hello world", 4), Some((0, 5)));
assert_eq!(find_word_boundaries("hello world", 6), Some((6, 11)));
assert_eq!(find_word_boundaries("hello world", 10), Some((6, 11)));
}
#[test]
fn test_find_word_boundaries_with_underscores() {
assert_eq!(find_word_boundaries("foo_bar_baz", 0), Some((0, 11)));
assert_eq!(find_word_boundaries("foo_bar_baz", 4), Some((0, 11)));
assert_eq!(find_word_boundaries("snake_case_name", 6), Some((0, 15)));
}
#[test]
fn test_find_word_boundaries_not_on_word() {
assert_eq!(find_word_boundaries("hello world", 5), None); assert_eq!(find_word_boundaries("foo.bar", 3), None); assert_eq!(find_word_boundaries("a + b", 2), None); }
#[test]
fn test_find_selection_boundaries_symbols() {
assert_eq!(find_selection_boundaries("/// comment", 0), Some((0, 3)));
assert_eq!(find_selection_boundaries("/// comment", 1), Some((0, 3)));
assert_eq!(find_selection_boundaries("/// comment", 2), Some((0, 3)));
assert_eq!(find_selection_boundaries("std::vec", 3), Some((3, 5)));
assert_eq!(find_selection_boundaries("std::vec", 4), Some((3, 5)));
assert_eq!(find_selection_boundaries("foo->bar", 3), Some((3, 5)));
assert_eq!(find_selection_boundaries("a := b", 2), Some((2, 4)));
}
#[test]
fn test_find_selection_boundaries_whitespace_selects_next() {
assert_eq!(find_selection_boundaries("hello world", 5), Some((6, 11)));
assert_eq!(find_selection_boundaries(" word", 0), Some((2, 6)));
assert_eq!(find_selection_boundaries(" word", 1), Some((2, 6)));
assert_eq!(find_selection_boundaries("foo /// bar", 4), Some((4, 7)));
assert_eq!(find_selection_boundaries("word ", 5), None);
}
#[test]
fn test_find_selection_boundaries_words() {
assert_eq!(find_selection_boundaries("hello world", 0), Some((0, 5)));
assert_eq!(find_selection_boundaries("hello world", 6), Some((6, 11)));
assert_eq!(find_selection_boundaries("foo_bar", 3), Some((0, 7)));
}
#[test]
fn test_find_word_boundaries_at_edges() {
assert_eq!(find_word_boundaries("word", 0), Some((0, 4)));
assert_eq!(find_word_boundaries("word", 3), Some((0, 4)));
assert_eq!(find_word_boundaries(" word ", 2), Some((2, 6)));
}
#[test]
fn test_find_word_boundaries_empty_and_out_of_bounds() {
assert_eq!(find_word_boundaries("", 0), None);
assert_eq!(find_word_boundaries("hello", 10), None);
}
#[test]
fn test_select_word_at_basic() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("hello world", false)];
app.select_word_at(15, 1);
let sel = app.view.selection.as_ref().expect("Should have selection");
assert_eq!(sel.start.col, 14); assert_eq!(sel.end.col, 19); assert!(sel.active, "Should be active to allow word-drag");
let anchor = app.view.word_selection_anchor.expect("Should have word anchor");
assert_eq!(anchor, (0, 14, 19));
}
#[test]
fn test_select_word_at_whitespace_selects_next_word() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("hello world", false)];
app.select_word_at(14, 1);
let sel = app.view.selection.as_ref().expect("Should have selection");
assert_eq!(sel.start.col, 14); assert_eq!(sel.end.col, 19); }
#[test]
fn test_select_word_at_symbols() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("/// comment", false)];
app.select_word_at(10, 1);
let sel = app.view.selection.as_ref().expect("Should have selection");
assert_eq!(sel.start.col, 8); assert_eq!(sel.end.col, 11); }
#[test]
fn test_select_word_at_past_end_of_line() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("hello", false)];
app.select_word_at(19, 1);
let sel = app.view.selection.as_ref().expect("Should select whole line");
assert_eq!(sel.start.col, 8); assert_eq!(sel.end.col, 13); }
#[test]
fn test_word_drag_extends_by_words() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("one two three four", false)];
app.select_word_at(13, 1);
let sel = app.view.selection.as_ref().unwrap();
assert_eq!(sel.start.col, 12); assert_eq!(sel.end.col, 15);
app.update_selection(23, 1);
let sel = app.view.selection.as_ref().unwrap();
assert_eq!(sel.start.col, 12); assert_eq!(sel.end.col, 26); }
#[test]
fn test_word_drag_backwards() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("one two three four", false)];
app.select_word_at(17, 1);
app.update_selection(9, 1);
let sel = app.view.selection.as_ref().unwrap();
assert_eq!(sel.start.col, 8); assert_eq!(sel.end.col, 21); }
#[test]
fn test_word_anchor_cleared_on_end_selection() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("hello world", false)];
app.select_word_at(15, 1);
assert!(app.view.word_selection_anchor.is_some());
app.end_selection();
assert!(app.view.word_selection_anchor.is_none());
}
#[test]
fn test_word_anchor_cleared_on_start_selection() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("hello world", false)];
app.select_word_at(15, 1);
assert!(app.view.word_selection_anchor.is_some());
app.start_selection(9, 1); assert!(app.view.word_selection_anchor.is_none());
}
#[test]
fn test_select_word_at_empty_line() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("", false)];
app.select_word_at(12, 1);
let sel = app.view.selection.as_ref().expect("Should have selection");
assert_eq!(sel.start.col, 8); assert_eq!(sel.end.col, 8); }
#[test]
fn test_word_drag_across_rows() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![
make_row("first line", false),
make_row("second line", false),
];
app.select_word_at(9, 1);
let sel = app.view.selection.as_ref().unwrap();
assert_eq!(sel.start.row, 0);
assert_eq!(sel.end.row, 0);
app.update_selection(9, 2);
let sel = app.view.selection.as_ref().unwrap();
assert_eq!(sel.start.row, 0);
assert_eq!(sel.start.col, 8); assert_eq!(sel.end.row, 1);
assert_eq!(sel.end.col, 14); }
#[test]
fn test_word_drag_to_whitespace_selects_next_word() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("one two", false)];
app.select_word_at(9, 1);
app.update_selection(13, 1);
let sel = app.view.selection.as_ref().unwrap();
assert_eq!(sel.start.col, 8); assert_eq!(sel.end.col, 17); }
#[test]
fn test_word_drag_to_trailing_whitespace() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("word ", false)];
app.select_word_at(9, 1);
app.update_selection(14, 1);
let sel = app.view.selection.as_ref().unwrap();
assert_eq!(sel.start.col, 8); assert_eq!(sel.end.col, 13); }
#[test]
fn test_select_past_eol_on_wrapped_line_selects_entire_logical_line() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![
make_row("first part ", false), make_row("second part", true), make_row("next line", false), ];
app.select_word_at(24, 2);
let sel = app.view.selection.as_ref().expect("Should have selection");
assert_eq!(sel.start.row, 0);
assert_eq!(sel.start.col, 8); assert_eq!(sel.end.row, 1);
assert_eq!(sel.end.col, 19); }
#[test]
fn test_select_past_eol_on_first_segment_of_wrapped_line() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![
make_row("part one ", false), make_row("part two ", true), make_row("part three", true), ];
app.select_word_at(21, 1);
let sel = app.view.selection.as_ref().expect("Should have selection");
assert_eq!(sel.start.row, 0);
assert_eq!(sel.start.col, 8);
assert_eq!(sel.end.row, 2);
assert_eq!(sel.end.col, 18); }
#[test]
fn test_select_line_at_basic() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("hello world", false)];
app.select_line_at(14, 1);
let sel = app.view.selection.as_ref().expect("Should have selection");
assert_eq!(sel.start.row, 0);
assert_eq!(sel.end.row, 0);
assert_eq!(sel.start.col, 8); assert_eq!(sel.end.col, 19); assert!(sel.active, "Should be active for line-drag");
let anchor = app.view.line_selection_anchor.expect("Should have line anchor");
assert_eq!(anchor, (0, 0)); }
#[test]
fn test_select_line_at_wrapped_line() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![
make_row("first part ", false), make_row("second part", true), ];
app.select_line_at(14, 2);
let sel = app.view.selection.as_ref().expect("Should have selection");
assert_eq!(sel.start.row, 0);
assert_eq!(sel.end.row, 1);
assert_eq!(sel.start.col, 8); assert_eq!(sel.end.col, 19);
let anchor = app.view.line_selection_anchor.expect("Should have line anchor");
assert_eq!(anchor, (0, 1)); }
#[test]
fn test_prefix_len_with_zero_line_num_width() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 0;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("hello", false)];
app.select_line_at(7, 1);
let sel = app.view.selection.as_ref().expect("Should have selection");
assert_eq!(sel.start.col, 4); assert_eq!(sel.end.col, 9); }
#[test]
fn test_select_word_at_with_zero_line_num_width() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 0;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("hello world", false)];
app.select_word_at(11, 1);
let sel = app.view.selection.as_ref().expect("Should have selection");
assert_eq!(sel.start.col, 10); assert_eq!(sel.end.col, 15); }
#[test]
fn test_has_non_empty_selection_point() {
let mut app = TestAppBuilder::new().build();
app.view.selection = Some(Selection {
start: Position { row: 0, col: 5 },
end: Position { row: 0, col: 5 },
active: false,
});
assert!(!app.has_non_empty_selection());
}
#[test]
fn test_has_non_empty_selection_range() {
let mut app = TestAppBuilder::new().build();
app.view.selection = Some(Selection {
start: Position { row: 0, col: 5 },
end: Position { row: 0, col: 10 },
active: false,
});
assert!(app.has_non_empty_selection());
}
#[test]
fn test_has_non_empty_selection_none() {
let app = TestAppBuilder::new().build();
assert!(!app.has_non_empty_selection());
}
#[test]
fn test_end_selection_after_drag_clears_selection() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("hello world", false)];
app.view.selection = Some(Selection {
start: Position { row: 0, col: 8 },
end: Position { row: 0, col: 14 },
active: true,
});
app.view.last_click = None;
app.end_selection_with_auto_copy();
assert!(app.view.pending_copy.is_none());
}
#[test]
fn test_end_selection_after_double_click_sets_pending_copy() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("hello world", false)];
app.view.selection = Some(Selection {
start: Position { row: 0, col: 8 },
end: Position { row: 0, col: 13 },
active: true,
});
app.view.last_click = Some((Instant::now(), 15, 1, 2));
app.end_selection_with_auto_copy();
assert!(app.view.pending_copy.is_some());
assert!(app.view.selection.is_some());
}
#[test]
fn test_end_selection_single_click_no_copy() {
let mut app = TestAppBuilder::new().build();
app.view.selection = Some(Selection {
start: Position { row: 0, col: 5 },
end: Position { row: 0, col: 5 },
active: true,
});
app.view.last_click = Some((Instant::now(), 5, 0, 1));
app.end_selection_with_auto_copy();
assert!(app.view.pending_copy.is_none());
}
#[test]
fn test_cancel_pending_copy() {
let mut app = TestAppBuilder::new().build();
app.view.pending_copy = Some(Instant::now());
app.cancel_pending_copy();
assert!(app.view.pending_copy.is_none());
}
#[test]
fn test_check_and_execute_pending_copy_too_soon() {
let mut app = TestAppBuilder::new().build();
app.view.pending_copy = Some(Instant::now());
app.view.row_map = vec![make_row("hello", false)];
app.view.selection = Some(Selection {
start: Position { row: 0, col: 0 },
end: Position { row: 0, col: 5 },
active: false,
});
let executed = app.check_and_execute_pending_copy();
assert!(!executed);
assert!(app.view.pending_copy.is_some());
}
#[test]
fn test_check_and_execute_pending_copy_after_timeout() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 0;
app.view.content_offset = (0, 0);
app.view.pending_copy = Some(Instant::now() - std::time::Duration::from_millis(600));
app.view.row_map = vec![make_row("hello", false)];
app.view.selection = Some(Selection {
start: Position { row: 0, col: 4 },
end: Position { row: 0, col: 9 },
active: false,
});
let executed = app.check_and_execute_pending_copy();
assert!(executed);
assert!(app.view.pending_copy.is_none());
}
#[test]
fn test_screen_to_content_position_status_bar() {
let mut app = TestAppBuilder::new().build();
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("line1", false), make_row("line2", false)];
app.view.status_bar_lines = vec!["status line".to_string()];
app.view.status_bar_screen_y = 10;
let pos = app.screen_to_content_position(5, 10);
assert!(pos.is_some());
let pos = pos.unwrap();
assert_eq!(pos.row, 2);
assert_eq!(pos.col, 5);
}
#[test]
fn test_screen_to_content_position_outside_all_areas() {
let mut app = TestAppBuilder::new().build();
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("line1", false)];
app.view.status_bar_lines = vec!["status".to_string()];
app.view.status_bar_screen_y = 10;
let pos = app.screen_to_content_position(0, 0);
assert!(pos.is_none());
}
#[test]
fn test_get_selected_text_from_status_bar() {
let mut app = TestAppBuilder::new().build();
app.view.content_offset = (1, 1);
app.view.line_num_width = 0;
app.view.row_map = vec![make_row("diff content", false)];
app.view.status_bar_lines = vec!["repo | feat vs main".to_string()];
app.view.status_bar_screen_y = 10;
app.view.selection = Some(Selection {
start: Position { row: 1, col: 7 },
end: Position { row: 1, col: 11 },
active: false,
});
let text = app.get_selected_text();
assert_eq!(text, Some("feat".to_string()));
}
#[test]
fn test_double_click_status_bar_selects_word() {
let mut app = TestAppBuilder::new().build();
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("diff line", false)];
app.view.status_bar_lines = vec!["repo | feature vs main".to_string()];
app.view.status_bar_screen_y = 5;
app.select_word_at(10, 5);
let sel = app.view.selection.as_ref().expect("Should have selection");
assert_eq!(sel.start.row, 1); assert_eq!(sel.start.col, 7); assert_eq!(sel.end.col, 14);
let text = app.get_selected_text();
assert_eq!(text, Some("feature".to_string()));
}
#[test]
fn test_triple_click_status_bar_selects_line() {
let mut app = TestAppBuilder::new().build();
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("diff line", false)];
app.view.status_bar_lines = vec!["repo | feat vs main".to_string()];
app.view.status_bar_screen_y = 5;
app.select_line_at(10, 5);
let sel = app.view.selection.as_ref().expect("Should have selection");
assert_eq!(sel.start.row, 1); assert_eq!(sel.start.col, 0); assert_eq!(sel.end.col, 19);
let text = app.get_selected_text();
assert_eq!(text, Some("repo | feat vs main".to_string()));
}
#[test]
fn test_double_click_status_bar_past_end_selects_line() {
let mut app = TestAppBuilder::new().build();
app.view.content_offset = (1, 1);
app.view.row_map = vec![make_row("diff line", false)];
app.view.status_bar_lines = vec!["short".to_string()];
app.view.status_bar_screen_y = 5;
app.select_word_at(50, 5);
let sel = app.view.selection.as_ref().expect("Should have selection");
assert_eq!(sel.start.col, 0);
assert_eq!(sel.end.col, 5); }
#[test]
fn get_selected_text_cjk_single_row() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3; app.view.row_map = vec![make_row("hi你好", false)];
app.view.selection = Some(Selection {
start: Position { row: 0, col: 10 }, end: Position { row: 0, col: 12 }, active: false,
});
let text = app.get_selected_text().unwrap();
assert_eq!(text, "你", "should select one CJK char at display cols 2-4");
}
#[test]
fn get_selected_text_cjk_from_start() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.row_map = vec![make_row("你好world", false)];
app.view.selection = Some(Selection {
start: Position { row: 0, col: 8 },
end: Position { row: 0, col: 12 }, active: false,
});
let text = app.get_selected_text().unwrap();
assert_eq!(text, "你好");
}
#[test]
fn get_selected_text_cjk_multirow() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.row_map = vec![
make_row("你好world", false), make_row("hello", false), ];
app.view.selection = Some(Selection {
start: Position { row: 0, col: 12 }, end: Position { row: 1, col: 13 }, active: false,
});
let text = app.get_selected_text().unwrap();
assert_eq!(text, "world\nhello");
}
#[test]
fn find_selection_boundaries_cjk_word() {
let result = find_selection_boundaries("你好 world", 0);
assert_eq!(result, Some((0, 4)), "CJK symbols 你好 span display cols 0-4");
}
#[test]
fn find_selection_boundaries_ascii_after_cjk() {
let result = find_selection_boundaries("你好 world", 5);
assert_eq!(result, Some((5, 10)), "word 'world' spans display cols 5-10");
}
#[test]
fn select_line_at_cjk_content_uses_display_width() {
let mut app = TestAppBuilder::new().build();
app.view.line_num_width = 3;
app.view.content_offset = (1, 1);
app.view.row_map = vec![
make_row("你好世界", false), ];
app.select_line_at(5, 1);
let sel = app.view.selection.as_ref().expect("should have selection");
assert_eq!(sel.end.col, 16, "end col should use display width, not char count (4)");
}
}