use hjkl_buffer::{Edit, MotionKind, Position};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Sel {
pub anchor: Position,
pub head: Position,
}
impl Sel {
pub fn new(anchor: Position, head: Position) -> Self {
Self { anchor, head }
}
pub fn caret(p: Position) -> Self {
Self { anchor: p, head: p }
}
pub fn is_caret(&self) -> bool {
self.anchor == self.head
}
pub fn start(&self) -> Position {
self.anchor.min(self.head)
}
pub fn end(&self) -> Position {
self.anchor.max(self.head)
}
}
fn key(p: Position) -> (usize, usize) {
(p.row, p.col)
}
fn after_insert(p: Position, at: Position, text: &str) -> Position {
if key(p) < key(at) {
return p;
}
let added_rows = text.matches('\n').count();
let tail = text.rsplit('\n').next().unwrap_or("");
let tail_len = tail.chars().count();
if p.row == at.row {
if added_rows == 0 {
Position::new(p.row, p.col + text.chars().count())
} else {
Position::new(p.row + added_rows, tail_len + (p.col - at.col))
}
} else {
Position::new(p.row + added_rows, p.col)
}
}
fn after_delete_char(p: Position, start: Position, end: Position) -> Position {
if key(p) <= key(start) {
return p;
}
if key(p) < key(end) {
return start;
}
if p.row == end.row {
Position::new(start.row, start.col + (p.col - end.col))
} else {
Position::new(p.row - (end.row - start.row), p.col)
}
}
fn after_delete_lines(p: Position, start_row: usize, end_row: usize) -> Position {
if p.row < start_row {
p
} else if p.row <= end_row {
Position::new(start_row, 0)
} else {
Position::new(p.row - (end_row - start_row + 1), p.col)
}
}
fn after_delete_block(p: Position, start: Position, end: Position) -> Position {
let (lo_row, hi_row) = (start.row.min(end.row), start.row.max(end.row));
let (lo_col, hi_col) = (start.col.min(end.col), start.col.max(end.col));
if p.row < lo_row || p.row > hi_row {
return p;
}
let width = hi_col - lo_col + 1;
if p.col > hi_col {
Position::new(p.row, p.col - width)
} else if p.col >= lo_col {
Position::new(p.row, lo_col)
} else {
p
}
}
fn after_join(
p: Position,
row: usize,
count: usize,
with_space: bool,
line_len: &impl Fn(usize) -> usize,
rows: usize,
) -> Position {
if p.row < row {
return p;
}
let mut cur_len = line_len(row);
let mut start_col = Vec::with_capacity(count);
let mut joined = 0usize;
for k in 1..=count.max(1) {
if row + k >= rows {
break;
}
let next_len = line_len(row + k);
let space = with_space && cur_len > 0 && next_len > 0;
start_col.push(cur_len + usize::from(space));
cur_len += usize::from(space) + next_len;
joined += 1;
}
if joined == 0 || p.row == row {
return p;
}
if p.row <= row + joined {
let k = p.row - row; Position::new(row, start_col[k - 1] + p.col)
} else {
Position::new(p.row - joined, p.col)
}
}
fn after_insert_block(p: Position, at: Position, chunks: &[String]) -> Position {
if p.row < at.row || p.row >= at.row + chunks.len() || p.col < at.col {
return p;
}
let width = chunks[p.row - at.row].chars().count();
Position::new(p.row, p.col + width)
}
fn after_delete_block_chunks(p: Position, at: Position, widths: &[usize]) -> Position {
if p.row < at.row || p.row >= at.row + widths.len() || p.col <= at.col {
return p;
}
let w = widths[p.row - at.row];
if p.col >= at.col + w {
Position::new(p.row, p.col - w)
} else {
Position::new(p.row, at.col)
}
}
pub fn shift_position(
p: Position,
edit: &Edit,
line_len: impl Fn(usize) -> usize,
rows: usize,
) -> Option<Position> {
match edit {
Edit::InsertChar { at, ch } => {
let mut buf = [0u8; 4];
Some(after_insert(p, *at, ch.encode_utf8(&mut buf)))
}
Edit::InsertStr { at, text } => Some(after_insert(p, *at, text)),
Edit::DeleteRange { start, end, kind } => Some(match kind {
MotionKind::Char => after_delete_char(p, *start, *end),
MotionKind::Line => after_delete_lines(p, start.row, end.row),
MotionKind::Block => after_delete_block(p, *start, *end),
}),
Edit::Replace { start, end, with } => {
let deleted = after_delete_char(p, *start, *end);
Some(after_insert(deleted, *start, with))
}
Edit::JoinLines {
row,
count,
with_space,
} => Some(after_join(p, *row, *count, *with_space, &line_len, rows)),
Edit::InsertBlock { at, chunks } => Some(after_insert_block(p, *at, chunks)),
Edit::DeleteBlockChunks { at, widths } => Some(after_delete_block_chunks(p, *at, widths)),
Edit::SplitLines { .. } => None,
}
}
pub fn shift_sel(
sel: Sel,
edit: &Edit,
line_len: impl Fn(usize) -> usize,
rows: usize,
) -> Option<Sel> {
let anchor = shift_position(sel.anchor, edit, &line_len, rows)?;
let head = shift_position(sel.head, edit, &line_len, rows)?;
Some(Sel { anchor, head })
}
#[cfg(test)]
mod tests {
use super::*;
fn p(row: usize, col: usize) -> Position {
Position::new(row, col)
}
fn ins(row: usize, col: usize, text: &str) -> Edit {
Edit::InsertStr {
at: p(row, col),
text: text.to_string(),
}
}
fn del(s: (usize, usize), e: (usize, usize), kind: MotionKind) -> Edit {
Edit::DeleteRange {
start: p(s.0, s.1),
end: p(e.0, e.1),
kind,
}
}
fn head(row: usize, col: usize, edit: &Edit) -> Option<Position> {
shift_position(p(row, col), edit, |_| 0, 0)
}
fn head_in(row: usize, col: usize, edit: &Edit, lens: &[usize]) -> Option<Position> {
shift_position(p(row, col), edit, |r| lens[r], lens.len())
}
#[test]
fn insert_before_on_same_row_pushes_right() {
assert_eq!(head(0, 5, &ins(0, 2, "ab")), Some(p(0, 7)));
}
#[test]
fn insert_after_on_same_row_does_not_move() {
assert_eq!(head(0, 1, &ins(0, 2, "ab")), Some(p(0, 1)));
}
#[test]
fn position_exactly_at_insertion_point_moves_right() {
assert_eq!(head(0, 2, &ins(0, 2, "xy")), Some(p(0, 2 + 2)));
}
#[test]
fn insert_on_earlier_row_does_not_move_later_col() {
assert_eq!(head(3, 4, &ins(1, 0, "abc")), Some(p(3, 4)));
}
#[test]
fn multiline_insert_pushes_later_rows_down() {
assert_eq!(head(3, 4, &ins(1, 0, "a\nb\n")), Some(p(5, 4)));
}
#[test]
fn multiline_insert_relocates_tail_of_the_insert_row() {
assert_eq!(head(0, 2, &ins(0, 2, "X\nY")), Some(p(1, 1)));
}
#[test]
fn insert_char_newline_restructures_like_a_string() {
let e = Edit::InsertChar {
at: p(0, 2),
ch: '\n',
};
assert_eq!(head(0, 5, &e), Some(p(1, 3)));
}
#[test]
fn delete_before_pulls_left() {
assert_eq!(
head(0, 9, &del((0, 2), (0, 5), MotionKind::Char)),
Some(p(0, 6))
);
}
#[test]
fn delete_after_does_not_move() {
assert_eq!(
head(0, 1, &del((0, 2), (0, 5), MotionKind::Char)),
Some(p(0, 1))
);
}
#[test]
fn position_inside_deleted_range_collapses_to_start() {
assert_eq!(
head(0, 3, &del((0, 2), (0, 5), MotionKind::Char)),
Some(p(0, 2))
);
}
#[test]
fn delete_end_is_exclusive() {
assert_eq!(
head(0, 5, &del((0, 2), (0, 5), MotionKind::Char)),
Some(p(0, 2))
);
}
#[test]
fn cross_row_delete_folds_tail_onto_start_row() {
assert_eq!(
head(3, 6, &del((1, 2), (3, 4), MotionKind::Char)),
Some(p(1, 4))
);
}
#[test]
fn row_below_a_cross_row_delete_shifts_up() {
assert_eq!(
head(7, 3, &del((1, 2), (3, 4), MotionKind::Char)),
Some(p(5, 3))
);
}
#[test]
fn linewise_delete_shifts_rows_below_up() {
assert_eq!(
head(9, 3, &del((2, 0), (4, 0), MotionKind::Line)),
Some(p(6, 3))
);
}
#[test]
fn linewise_delete_of_the_selections_own_row_collapses_it() {
assert_eq!(
head(3, 7, &del((2, 0), (4, 0), MotionKind::Line)),
Some(p(2, 0))
);
}
#[test]
fn linewise_delete_above_leaves_earlier_rows_alone() {
assert_eq!(
head(1, 7, &del((2, 0), (4, 0), MotionKind::Line)),
Some(p(1, 7))
);
}
#[test]
fn block_delete_pulls_columns_right_of_the_rectangle_left() {
assert_eq!(
head(2, 9, &del((1, 2), (3, 5), MotionKind::Block)),
Some(p(2, 5))
);
}
#[test]
fn block_delete_collapses_columns_inside_the_rectangle() {
assert_eq!(
head(2, 3, &del((1, 2), (3, 5), MotionKind::Block)),
Some(p(2, 2))
);
}
#[test]
fn block_delete_leaves_rows_outside_the_rectangle_alone() {
assert_eq!(
head(9, 9, &del((1, 2), (3, 5), MotionKind::Block)),
Some(p(9, 9))
);
}
#[test]
fn replace_shorter_pulls_left() {
let e = Edit::Replace {
start: p(0, 2),
end: p(0, 6),
with: "x".to_string(),
};
assert_eq!(head(0, 8, &e), Some(p(0, 5)));
}
#[test]
fn replace_longer_pushes_right() {
let e = Edit::Replace {
start: p(0, 2),
end: p(0, 3),
with: "xyz".to_string(),
};
assert_eq!(head(0, 5, &e), Some(p(0, 7)));
}
#[test]
fn join_folds_the_next_row_up_after_the_anchor_plus_a_space() {
let e = Edit::JoinLines {
row: 0,
count: 1,
with_space: true,
};
assert_eq!(head_in(1, 1, &e, &[3, 2]), Some(p(0, 5)));
}
#[test]
fn join_without_space_folds_flush() {
let e = Edit::JoinLines {
row: 0,
count: 1,
with_space: false,
};
assert_eq!(head_in(1, 1, &e, &[3, 2]), Some(p(0, 4)));
}
#[test]
fn join_inserts_no_space_when_a_side_is_empty() {
let e = Edit::JoinLines {
row: 0,
count: 1,
with_space: true,
};
assert_eq!(
head_in(1, 1, &e, &[0, 2]),
Some(p(0, 1)),
"empty prefix -> no space"
);
}
#[test]
fn join_leaves_the_anchor_rows_own_columns_alone() {
let e = Edit::JoinLines {
row: 0,
count: 1,
with_space: true,
};
assert_eq!(head_in(0, 2, &e, &[3, 2]), Some(p(0, 2)));
}
#[test]
fn join_pulls_rows_below_the_joined_span_up() {
let e = Edit::JoinLines {
row: 0,
count: 1,
with_space: true,
};
assert_eq!(head_in(3, 1, &e, &[3, 2, 4, 4]), Some(p(2, 1)));
}
#[test]
fn multi_row_join_accumulates_each_rows_offset() {
let e = Edit::JoinLines {
row: 0,
count: 2,
with_space: true,
};
assert_eq!(head_in(2, 0, &e, &[2, 2, 2]), Some(p(0, 6)));
}
#[test]
fn block_insert_pushes_columns_at_or_after_the_block_right() {
let e = Edit::InsertBlock {
at: p(0, 2),
chunks: vec!["xx".into(), "xx".into()],
};
assert_eq!(head(1, 4, &e), Some(p(1, 6)));
}
#[test]
fn block_insert_leaves_columns_before_the_block_alone() {
let e = Edit::InsertBlock {
at: p(0, 2),
chunks: vec!["xx".into(), "xx".into()],
};
assert_eq!(head(1, 1, &e), Some(p(1, 1)));
}
#[test]
fn block_insert_leaves_rows_outside_the_block_alone() {
let e = Edit::InsertBlock {
at: p(0, 2),
chunks: vec!["xx".into()],
};
assert_eq!(head(5, 4, &e), Some(p(5, 4)));
}
#[test]
fn block_chunk_delete_pulls_columns_after_the_chunk_left() {
let e = Edit::DeleteBlockChunks {
at: p(0, 2),
widths: vec![2, 2],
};
assert_eq!(head(1, 6, &e), Some(p(1, 4)));
}
#[test]
fn block_chunk_delete_collapses_columns_inside_the_chunk() {
let e = Edit::DeleteBlockChunks {
at: p(0, 2),
widths: vec![3],
};
assert_eq!(head(0, 3, &e), Some(p(0, 2)));
}
#[test]
fn a_selection_shifts_both_of_its_ends() {
let s = Sel::new(p(0, 2), p(0, 5));
assert_eq!(
shift_sel(s, &ins(0, 0, "XY"), |_| 0, 1),
Some(Sel::new(p(0, 4), p(0, 7)))
);
}
#[test]
fn a_backwards_selection_keeps_its_direction() {
let s = Sel::new(p(0, 5), p(0, 2));
let out = shift_sel(s, &ins(0, 0, "XY"), |_| 0, 1).unwrap();
assert_eq!(out.anchor, p(0, 7));
assert_eq!(out.head, p(0, 4));
assert!(out.anchor > out.head, "direction must survive the shift");
}
#[test]
fn a_selection_whose_text_is_deleted_collapses_to_the_hole() {
let s = Sel::new(p(0, 3), p(0, 5));
assert_eq!(
shift_sel(s, &del((0, 2), (0, 6), MotionKind::Char), |_| 0, 1),
Some(Sel::caret(p(0, 2))),
"both ends collapse to the deletion start — a caret, not a stale range"
);
}
#[test]
fn a_selection_is_dropped_whole_when_either_end_is_untrackable() {
let e = Edit::SplitLines {
row: 0,
cols: vec![3],
inserted_space: true,
};
assert_eq!(
shift_sel(Sel::new(p(1, 0), p(1, 4)), &e, |_| 0, 0),
None,
"never half-track: a selection with one guessed end edits the wrong text"
);
}
#[test]
fn split_lines_drops_rather_than_guessing() {
let e = Edit::SplitLines {
row: 0,
cols: vec![3],
inserted_space: true,
};
assert_eq!(shift_position(p(5, 0), &e, |_| 0, 0), None);
}
}