#[derive(Debug, Clone, PartialEq)]
pub struct Selection {
pub active: bool,
pub start: (usize, usize), pub end: (usize, usize), }
impl Selection {
pub fn new() -> Self {
Self {
active: false,
start: (0, 0),
end: (0, 0),
}
}
pub fn start_at(&mut self, pos: (usize, usize)) {
self.active = true;
self.start = pos;
self.end = pos;
}
pub fn extend_to(&mut self, pos: (usize, usize)) {
if self.active {
self.end = pos;
}
}
pub fn clear(&mut self) {
self.active = false;
}
pub fn is_empty(&self) -> bool {
!self.active || self.start == self.end
}
pub fn set_span(&mut self, start: (usize, usize), end: (usize, usize)) {
self.active = true;
self.start = start;
self.end = end;
}
pub fn bounds(&self) -> ((usize, usize), (usize, usize)) {
let (r1, c1) = self.start;
let (r2, c2) = self.end;
if r1 < r2 || (r1 == r2 && c1 <= c2) {
(self.start, self.end)
} else {
(self.end, self.start)
}
}
pub fn contains(&self, row: usize, col: usize) -> bool {
if !self.active {
return false;
}
let (start, end) = self.bounds();
let (r1, c1) = start;
let (r2, c2) = end;
if row < r1 || row > r2 {
return false;
}
if row == r1 && col < c1 {
return false;
}
if row == r2 && col > c2 {
return false;
}
true
}
pub fn row_fully_selected(&self, row: usize) -> bool {
if !self.active {
return false;
}
let (start, end) = self.bounds();
row > start.0 && row < end.0
}
pub fn span_in_row(&self, row: usize, row_len: usize) -> Option<(usize, usize)> {
if !self.active {
return None;
}
let (start, end) = self.bounds();
let (r1, c1) = start;
let (r2, c2) = end;
if row < r1 || row > r2 {
return None;
}
if row == r1 && row == r2 {
if c1 < row_len {
Some((c1, (c2 + 1).min(row_len)))
} else {
None
}
} else if row == r1 {
if c1 < row_len {
Some((c1, row_len))
} else {
None
}
} else if row == r2 {
Some((0, (c2 + 1).min(row_len)))
} else {
Some((0, row_len))
}
}
pub fn extract_text<F>(&self, row_text: F) -> String
where
F: Fn(usize) -> Option<String>,
{
if !self.active {
return String::new();
}
let (start, end) = self.bounds();
let (r1, c1) = start;
let (r2, c2) = end;
let mut result = String::new();
for row in r1..=r2 {
if let Some(text) = row_text(row) {
let chars: Vec<usize> = text.char_indices().map(|(i, _)| i).collect();
let byte_end = text.len();
let c1b = *chars
.get(c1)
.unwrap_or(&(chars.last().copied().unwrap_or(0)));
let c2b = if c2 + 1 < chars.len() {
chars[c2 + 1]
} else {
byte_end
};
let c1b_to_end = *chars.get(c1).unwrap_or(&byte_end);
if row == r1 && row == r2 {
result.push_str(&text[c1b..c2b]);
} else if row == r1 {
result.push_str(&text[c1b_to_end..]);
result.push('\n');
} else if row == r2 {
result.push_str(&text[..c2b]);
} else {
result.push_str(&text);
result.push('\n');
}
}
}
result
}
pub fn extract_text_by_cell<F>(&self, row_text: F) -> String
where
F: Fn(usize) -> Option<(String, Vec<usize>)>,
{
if !self.active {
return String::new();
}
let (start, end) = self.bounds();
let (r1, c1) = start;
let (r2, c2) = end;
let mut result = String::new();
for row in r1..=r2 {
let Some((text, offsets)) = row_text(row) else {
continue;
};
let row_len = offsets.len().saturating_sub(1);
let start_col = if row == r1 { c1 } else { 0 }.min(row_len);
let end_col = if row == r2 {
c2.saturating_add(1)
} else {
row_len
}
.min(row_len);
let start_col = expand_start_over_continuation(&offsets, start_col, row_len);
let start_byte = byte_for_cell_col(&offsets, start_col, text.len());
let end_byte = byte_for_cell_col(&offsets, end_col, text.len()).max(start_byte);
let segment = text[start_byte..end_byte].trim_end_matches(' ');
result.push_str(segment);
if row != r2 {
result.push('\n');
}
}
result
}
}
fn byte_for_cell_col(offsets: &[usize], col: usize, text_len: usize) -> usize {
offsets.get(col).copied().unwrap_or(text_len).min(text_len)
}
fn expand_start_over_continuation(offsets: &[usize], mut col: usize, row_len: usize) -> usize {
col = col.min(row_len);
while col > 0 && col < row_len && offsets.get(col).copied() == offsets.get(col + 1).copied() {
col -= 1;
}
col
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_text_by_cell_keeps_grapheme_cluster_intact() {
let mut selection = Selection::new();
selection.set_span((0, 0), (0, 1));
let text = "👍🏽x ".to_string();
let offsets = vec![
0,
"👍🏽".len(),
"👍🏽".len(),
"👍🏽".len(),
"👍🏽".len(),
"👍🏽x".len(),
"👍🏽x ".len(),
text.len(),
];
let copied = selection.extract_text_by_cell(|_| Some((text.clone(), offsets.clone())));
assert_eq!(copied, "👍🏽");
}
#[test]
fn extract_text_by_cell_trims_padding_at_row_end() {
let mut selection = Selection::new();
selection.set_span((0, 0), (0, 9));
let text = "hello ".to_string();
let offsets: Vec<usize> = (0..=text.len()).collect();
let copied = selection.extract_text_by_cell(|_| Some((text.clone(), offsets.clone())));
assert_eq!(copied, "hello");
}
#[test]
fn extract_text_by_cell_trims_selected_trailing_padding() {
let mut selection = Selection::new();
selection.set_span((0, 0), (0, 7));
let text = "hello ".to_string();
let offsets: Vec<usize> = (0..=text.len()).collect();
let copied = selection.extract_text_by_cell(|_| Some((text.clone(), offsets.clone())));
assert_eq!(copied, "hello");
}
#[test]
fn extract_text_by_cell_expands_start_from_wide_continuation() {
let mut selection = Selection::new();
selection.set_span((0, 1), (0, 1));
let text = "好x".to_string();
let offsets = vec![0, "好".len(), "好".len(), text.len()];
let copied = selection.extract_text_by_cell(|_| Some((text.clone(), offsets.clone())));
assert_eq!(copied, "好");
}
#[test]
fn extract_text_by_cell_keeps_next_cell_after_wide_cluster_precise() {
let mut selection = Selection::new();
selection.set_span((0, 2), (0, 2));
let text = "好x".to_string();
let offsets = vec![0, "好".len(), "好".len(), text.len()];
let copied = selection.extract_text_by_cell(|_| Some((text.clone(), offsets.clone())));
assert_eq!(copied, "x");
}
#[test]
fn extract_text_by_cell_copies_mixed_emoji_and_cjk_without_padding() {
let mut selection = Selection::new();
selection.set_span((0, 14), (0, 22));
let text = "unicode: café 👍🏽 你好".to_string();
let emoji = "👍🏽";
let ni = "你";
let hao = "好";
let prefix = "unicode: café ";
let offsets = vec![
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
prefix.len(),
prefix.len() + emoji.len(),
prefix.len() + emoji.len(),
prefix.len() + emoji.len(),
prefix.len() + emoji.len(),
prefix.len() + emoji.len() + 1,
prefix.len() + emoji.len() + 1 + ni.len(),
prefix.len() + emoji.len() + 1 + ni.len(),
prefix.len() + emoji.len() + 1 + ni.len() + hao.len(),
text.len(),
];
let copied = selection.extract_text_by_cell(|_| Some((text.clone(), offsets.clone())));
assert_eq!(copied, "👍🏽 你好");
}
}