diff --git a/src/highlight.rs b/src/highlight.rs index c8bb8b9..70490ac 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -158,22 +158,22 @@ impl<'a> LineHighlighter<'a> { pub fn selection( &mut self, - row: usize, + current_row: usize, start_row: usize, start_off: usize, end_row: usize, end_off: usize, ) { - let (start, end) = if row == start_row { + let (start, end) = if current_row == start_row { if start_row == end_row { (start_off, end_off) } else { self.select_at_end = true; (start_off, self.line.len()) } - } else if row == end_row { + } else if current_row == end_row { (0, end_off) - } else if start_row < row && row < end_row { + } else if start_row < current_row && current_row < end_row { self.select_at_end = true; (0, self.line.len()) } else { diff --git a/src/history.rs b/src/history.rs index 5ca874c..ea63c6d 100644 --- a/src/history.rs +++ b/src/history.rs @@ -1,69 +1,75 @@ +use crate::util::Pos; use std::collections::VecDeque; #[derive(Clone, Debug)] pub enum EditKind { - InsertChar(char, usize), - DeleteChar(char, usize), - InsertNewline(usize), - DeleteNewline(usize), - InsertStr(String, usize), - DeleteStr(String, usize), - InsertChunk(Vec, usize, usize), - DeleteChunk(Vec, usize, usize), + InsertChar(char), + DeleteChar(char), + InsertNewline, + DeleteNewline, + InsertStr(String), + DeleteStr(String), + InsertChunk(Vec), + DeleteChunk(Vec), } impl EditKind { - pub(crate) fn apply(&self, row: usize, lines: &mut Vec) { + pub(crate) fn apply(&self, lines: &mut Vec, before: &Pos, after: &Pos) { match self { - EditKind::InsertChar(c, i) => { - lines[row].insert(*i, *c); + EditKind::InsertChar(c) => { + lines[before.row].insert(before.offset, *c); } - EditKind::DeleteChar(_, i) => { - lines[row].remove(*i); + EditKind::DeleteChar(_) => { + lines[before.row].remove(after.offset); } - EditKind::InsertNewline(i) => { - let line = &mut lines[row]; - let next_line = line[*i..].to_string(); - line.truncate(*i); - lines.insert(row + 1, next_line); + EditKind::InsertNewline => { + let line = &mut lines[before.row]; + let next_line = line[before.offset..].to_string(); + line.truncate(before.offset); + lines.insert(before.row + 1, next_line); } - EditKind::DeleteNewline(_) => { - if row > 0 { - let line = lines.remove(row); - lines[row - 1].push_str(&line); - } + EditKind::DeleteNewline => { + debug_assert!(before.row > 0, "invalid pos: {:?}", before); + let line = lines.remove(before.row); + lines[before.row - 1].push_str(&line); } - EditKind::InsertStr(s, i) => { - lines[row].insert_str(*i, s.as_str()); + EditKind::InsertStr(s) => { + lines[before.row].insert_str(before.offset, s.as_str()); } - EditKind::DeleteStr(s, i) => { - let end = *i + s.len(); - lines[row].replace_range(*i..end, ""); + EditKind::DeleteStr(s) => { + lines[after.row].drain(after.offset..after.offset + s.len()); } - EditKind::InsertChunk(c, row, i) => { + EditKind::InsertChunk(c) => { debug_assert!(c.len() > 1, "Chunk size must be > 1: {:?}", c); - let row = *row; // Handle first line of chunk - let first_line = &mut lines[row]; - let mut last_line = first_line.drain(*i..).as_str().to_string(); + let first_line = &mut lines[before.row]; + let mut last_line = first_line.drain(before.offset..).as_str().to_string(); first_line.push_str(&c[0]); // Handle last line of chunk + let next_row = before.row + 1; last_line.insert_str(0, c.last().unwrap()); - lines.insert(row + 1, last_line); + lines.insert(next_row, last_line); - // Handle last line of chunk - lines.splice(row + 1..row + 1, c[1..c.len() - 1].iter().cloned()); + // Handle middle lines of chunk + lines.splice(next_row..next_row, c[1..c.len() - 1].iter().cloned()); } - EditKind::DeleteChunk(c, row, i) => { + EditKind::DeleteChunk(c) => { debug_assert!(c.len() > 1, "Chunk size must be > 1: {:?}", c); - let row = *row; - lines[row].truncate(*i); - let mut last_line = lines.drain(row + 1..row + c.len()).last().unwrap(); + // Remove middle lines of chunk + let mut last_line = lines + .drain(after.row + 1..after.row + c.len()) + .last() + .unwrap(); + // Remove last line of chunk last_line.drain(..c[c.len() - 1].len()); - lines[row].push_str(&last_line); + + // Remove first line of chunk and concat remaining + let first_line = &mut lines[after.row]; + first_line.truncate(after.offset); + first_line.push_str(&last_line); } } } @@ -71,14 +77,14 @@ impl EditKind { fn invert(&self) -> Self { use EditKind::*; match self.clone() { - InsertChar(c, i) => DeleteChar(c, i), - DeleteChar(c, i) => InsertChar(c, i), - InsertNewline(i) => DeleteNewline(i), - DeleteNewline(i) => InsertNewline(i), - InsertStr(s, i) => DeleteStr(s, i), - DeleteStr(s, i) => InsertStr(s, i), - InsertChunk(c, r, i) => DeleteChunk(c, r, i), - DeleteChunk(c, r, i) => InsertChunk(c, r, i), + InsertChar(c) => DeleteChar(c), + DeleteChar(c) => InsertChar(c), + InsertNewline => DeleteNewline, + DeleteNewline => InsertNewline, + InsertStr(s) => DeleteStr(s), + DeleteStr(s) => InsertStr(s), + InsertChunk(c) => DeleteChunk(c), + DeleteChunk(c) => InsertChunk(c), } } } @@ -86,39 +92,33 @@ impl EditKind { #[derive(Clone, Debug)] pub struct Edit { kind: EditKind, - cursor_before: (usize, usize), - cursor_after: (usize, usize), + before: Pos, + after: Pos, } impl Edit { - pub fn new( - kind: EditKind, - cursor_before: (usize, usize), - cursor_after: (usize, usize), - ) -> Self { + pub fn new(kind: EditKind, before: Pos, after: Pos) -> Self { Self { kind, - cursor_before, - cursor_after, + before, + after, } } pub fn redo(&self, lines: &mut Vec) { - let (row, _) = self.cursor_before; - self.kind.apply(row, lines); + self.kind.apply(lines, &self.before, &self.after); } pub fn undo(&self, lines: &mut Vec) { - let (row, _) = self.cursor_after; - self.kind.invert().apply(row, lines); // Undo is redo of inverted edit + self.kind.invert().apply(lines, &self.after, &self.before); // Undo is redo of inverted edit } pub fn cursor_before(&self) -> (usize, usize) { - self.cursor_before + (self.before.row, self.before.col) } pub fn cursor_after(&self) -> (usize, usize) { - self.cursor_after + (self.after.row, self.after.col) } } @@ -185,7 +185,6 @@ mod tests { #[test] fn insert_delete_chunk() { #[rustfmt::skip] - #[allow(clippy::identity_op)] let tests = [ // Positions ( @@ -195,7 +194,7 @@ mod tests { "cd", "ef", ][..], - // (row, offset) position before edit + // (row, col) position before edit (0, 0), // Chunk to be inserted &[ @@ -551,7 +550,7 @@ mod tests { "🐮🐰", "🐧🐭", ][..], - (0, 4 * 2), + (0, 2), &[ "🐷", "🐼", "🐴", ][..], @@ -587,7 +586,7 @@ mod tests { "🐮🐰", "🐧🐭", ][..], - (1, 4 * 1), + (1, 1), &[ "🐷", "🐼", "🐴", ][..], @@ -605,7 +604,7 @@ mod tests { "🐮🐰", "🐧🐭", ][..], - (2, 4 * 2), + (2, 2), &[ "🐷", "🐼", "🐴", ][..], @@ -619,26 +618,33 @@ mod tests { ), ]; - for (before, pos, input, expected) in tests { - let (row, offset) = pos; + for test in tests { + let (before, pos, input, expected) = test; + let (row, col) = pos; + let before_pos = { + let offset = before[row] + .char_indices() + .map(|(i, _)| i) + .nth(col) + .unwrap_or(before[row].len()); + Pos::new(row, col, offset) + }; let mut lines: Vec<_> = before.iter().map(|s| s.to_string()).collect(); let chunk: Vec<_> = input.iter().map(|s| s.to_string()).collect(); + let after_pos = { + let row = row + input.len() - 1; + let last = input.last().unwrap(); + let col = last.chars().count(); + Pos::new(row, col, last.len()) + }; - let edit = EditKind::InsertChunk(chunk.clone(), row, offset); - edit.apply(row, &mut lines); - assert_eq!( - &lines, expected, - "{:?} at {:?} with {:?}", - before, pos, input, - ); + let edit = EditKind::InsertChunk(chunk.clone()); + edit.apply(&mut lines, &before_pos, &after_pos); + assert_eq!(&lines, expected, "{test:?}"); - let edit = EditKind::DeleteChunk(chunk, row, offset); - edit.apply(row, &mut lines); - assert_eq!( - &lines, &before, - "{:?} at {:?} with {:?}", - before, pos, input, - ); + let edit = EditKind::DeleteChunk(chunk); + edit.apply(&mut lines, &after_pos, &before_pos); + assert_eq!(&lines, &before, "{test:?}"); } } } diff --git a/src/textarea.rs b/src/textarea.rs index ac5bab8..3163c99 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -8,7 +8,7 @@ use crate::ratatui::widgets::{Block, Widget}; use crate::scroll::Scrolling; #[cfg(feature = "search")] use crate::search::Search; -use crate::util::spaces; +use crate::util::{spaces, Pos}; use crate::widget::{Renderer, Viewport}; use crate::word::{find_word_end_forward, find_word_start_backward}; #[cfg(feature = "ratatui")] @@ -54,18 +54,6 @@ impl ToString for YankText { } } -struct Pos { - row: usize, - col: usize, - offset: usize, -} - -impl Pos { - fn new(row: usize, col: usize, offset: usize) -> Self { - Self { row, col, offset } - } -} - /// A type to manage state of textarea. /// /// [`TextArea::default`] creates an empty textarea. [`TextArea::new`] creates a textarea with given text lines. @@ -715,8 +703,10 @@ impl<'a> TextArea<'a> { } } - fn push_history(&mut self, kind: EditKind, cursor_before: (usize, usize)) { - let edit = Edit::new(kind, cursor_before, self.cursor); + fn push_history(&mut self, kind: EditKind, before: Pos, after_offset: usize) { + let (row, col) = self.cursor; + let after = Pos::new(row, col, after_offset); + let edit = Edit::new(kind, before, after); self.history.push(edit); } @@ -745,7 +735,11 @@ impl<'a> TextArea<'a> { .unwrap_or(line.len()); line.insert(i, c); self.cursor.1 += 1; - self.push_history(EditKind::InsertChar(c, i), (row, col)); + self.push_history( + EditKind::InsertChar(c), + Pos::new(row, col, i), + i + c.len_utf8(), + ); } /// Insert a string at current cursor position. This method returns if some text was inserted or not in the textarea. @@ -780,16 +774,20 @@ impl<'a> TextArea<'a> { .nth(col) .map(|(i, _)| i) .unwrap_or(line.len()); + let before = Pos::new(row, col, i); - self.cursor = ( + let (row, col) = ( row + chunk.len() - 1, chunk[chunk.len() - 1].chars().count(), ); + self.cursor = (row, col); - let edit = EditKind::InsertChunk(chunk, row, i); - edit.apply(row, &mut self.lines); + let end_offset = chunk.last().unwrap().len(); - self.push_history(edit, (row, col)); + let edit = EditKind::InsertChunk(chunk); + edit.apply(&mut self.lines, &before, &Pos::new(row, col, end_offset)); + + self.push_history(edit, before, end_offset); true } @@ -812,45 +810,37 @@ impl<'a> TextArea<'a> { .map(|(i, _)| i) .unwrap_or(line.len()); line.insert_str(i, &s); + let end_offset = i + s.len(); self.cursor.1 += s.chars().count(); - self.push_history(EditKind::InsertStr(s, i), (row, col)); + self.push_history(EditKind::InsertStr(s), Pos::new(row, col, i), end_offset); true } - fn delete_offset_range( - &mut self, - start_row: usize, - start_col: usize, - start_offset: usize, - end_row: usize, - end_offset: usize, - should_yank: bool, - ) { - let cursor_before = self.cursor; - self.cursor = (start_row, start_col); + fn delete_range(&mut self, start: Pos, end: Pos, should_yank: bool) { + self.cursor = (start.row, start.col); - if start_row == end_row { - let removed = self.lines[start_row] - .drain(start_offset..end_offset) + if start.row == end.row { + let removed = self.lines[start.row] + .drain(start.offset..end.offset) .as_str() .to_string(); if should_yank { self.yank = removed.clone().into(); } - self.push_history(EditKind::DeleteStr(removed, start_offset), cursor_before); + self.push_history(EditKind::DeleteStr(removed), end, start.offset); return; } - let mut deleted = vec![self.lines[start_row] - .drain(start_offset..) + let mut deleted = vec![self.lines[start.row] + .drain(start.offset..) .as_str() .to_string()]; - deleted.extend(self.lines.drain(start_row + 1..end_row)); - if start_row + 1 < self.lines.len() { - let mut last_line = self.lines.remove(start_row + 1); - self.lines[start_row].push_str(&last_line[end_offset..]); - last_line.truncate(end_offset); + deleted.extend(self.lines.drain(start.row + 1..end.row)); + if start.row + 1 < self.lines.len() { + let mut last_line = self.lines.remove(start.row + 1); + self.lines[start.row].push_str(&last_line[end.offset..]); + last_line.truncate(end.offset); deleted.push(last_line); } @@ -859,11 +849,11 @@ impl<'a> TextArea<'a> { } let edit = if deleted.len() == 1 { - EditKind::DeleteStr(deleted.remove(0), start_offset) + EditKind::DeleteStr(deleted.remove(0)) } else { - EditKind::DeleteChunk(deleted, start_row, start_offset) + EditKind::DeleteChunk(deleted) }; - self.push_history(edit, cursor_before); + self.push_history(edit, end, start.offset); } /// Delete a string from the current cursor position. The `chars` parameter means number of characters, not a byte @@ -896,14 +886,16 @@ impl<'a> TextArea<'a> { let mut remaining = chars; let mut find_end = move |line: &str| { + let mut col = 0usize; for (i, _) in line.char_indices() { if remaining == 0 { - return Some(i); + return Some((i, col)); } + col += 1; remaining -= 1; } if remaining == 0 { - Some(line.len()) + Some((line.len(), col)) } else { remaining -= 1; None @@ -919,29 +911,39 @@ impl<'a> TextArea<'a> { }; // First line - if let Some(offset) = find_end(&line[start_offset..]) { + if let Some((offset_delta, col_delta)) = find_end(&line[start_offset..]) { + let end_offset = start_offset + offset_delta; + let end_col = start_col + col_delta; let removed = self.lines[start_row] - .drain(start_offset..start_offset + offset) + .drain(start_offset..end_offset) .as_str() .to_string(); self.yank = removed.clone().into(); - self.push_history(EditKind::DeleteStr(removed, start_offset), self.cursor); + self.push_history( + EditKind::DeleteStr(removed), + Pos::new(start_row, end_col, end_offset), + start_offset, + ); return true; } let mut r = start_row + 1; - let mut i = 0; + let mut offset = 0; + let mut col = 0; while r < self.lines.len() { let line = &self.lines[r]; - if let Some(offset) = find_end(line) { - i = offset; + if let Some((o, c)) = find_end(line) { + offset = o; + col = c; break; } r += 1; } - self.delete_offset_range(start_row, start_col, start_offset, r, i, true); + let start = Pos::new(start_row, start_col, start_offset); + let end = Pos::new(r, col, offset); + self.delete_range(start, end, true); true } @@ -950,20 +952,31 @@ impl<'a> TextArea<'a> { return false; } - let cursor_before = self.cursor; - let row = cursor_before.0; + #[inline] + fn bytes_and_chars(claimed: usize, s: &str) -> (usize, usize) { + // Note: `claimed` may be larger than characters in `s` (e.g. usize::MAX) + let mut last_col = 0; + for (col, (bytes, _)) in s.char_indices().enumerate() { + if col == claimed { + return (bytes, claimed); + } + last_col = col; + } + (s.len(), last_col + 1) + } + + let (row, _) = self.cursor; let line = &mut self.lines[row]; if let Some((i, _)) = line.char_indices().nth(col) { - let bytes = line[i..] - .char_indices() - .nth(chars) - .map(|(i, _)| i) - .unwrap_or_else(|| line[i..].len()); - let removed = line[i..i + bytes].to_string(); - line.replace_range(i..i + bytes, ""); + let (bytes, chars) = bytes_and_chars(chars, &line[i..]); + let removed = line.drain(i..i + bytes).as_str().to_string(); self.cursor = (row, col); - self.push_history(EditKind::DeleteStr(removed.clone(), i), cursor_before); + self.push_history( + EditKind::DeleteStr(removed.clone()), + Pos::new(row, col + chars, i + bytes), + i, + ); self.yank = removed.into(); true } else { @@ -1021,17 +1034,17 @@ impl<'a> TextArea<'a> { let (row, col) = self.cursor; let line = &mut self.lines[row]; - let idx = line + let offset = line .char_indices() .nth(col) .map(|(i, _)| i) .unwrap_or(line.len()); - let next_line = line[idx..].to_string(); - line.truncate(idx); + let next_line = line[offset..].to_string(); + line.truncate(offset); self.lines.insert(row + 1, next_line); self.cursor = (row + 1, 0); - self.push_history(EditKind::InsertNewline(idx), (row, col)); + self.push_history(EditKind::InsertNewline, Pos::new(row, col, offset), 0); } /// Delete a newline from **head** of current cursor line. This method returns if a newline was deleted or not in @@ -1050,7 +1063,7 @@ impl<'a> TextArea<'a> { return true; } - let (row, col) = self.cursor; + let (row, _) = self.cursor; if row == 0 { return false; } @@ -1061,7 +1074,7 @@ impl<'a> TextArea<'a> { self.cursor = (row - 1, prev_line.chars().count()); prev_line.push_str(&line); - self.push_history(EditKind::DeleteNewline(prev_line_end), (row, col)); + self.push_history(EditKind::DeleteNewline, Pos::new(row, 0, 0), prev_line_end); true } @@ -1088,10 +1101,14 @@ impl<'a> TextArea<'a> { } let line = &mut self.lines[row]; - if let Some((i, c)) = line.char_indices().nth(col - 1) { - line.remove(i); + if let Some((offset, c)) = line.char_indices().nth(col - 1) { + line.remove(offset); self.cursor.1 -= 1; - self.push_history(EditKind::DeleteChar(c, i), (row, col)); + self.push_history( + EditKind::DeleteChar(c), + Pos::new(row, col, offset + c.len_utf8()), + offset, + ); true } else { false @@ -1427,7 +1444,7 @@ impl<'a> TextArea<'a> { fn delete_selection(&mut self, should_yank: bool) -> bool { if let Some((s, e)) = self.take_selection_range() { - self.delete_offset_range(s.row, s.col, s.offset, e.row, e.offset, should_yank); + self.delete_range(s, e, should_yank); return true; } false diff --git a/src/util.rs b/src/util.rs index f0e90e0..f001290 100644 --- a/src/util.rs +++ b/src/util.rs @@ -6,3 +6,16 @@ pub fn spaces(size: u8) -> &'static str { pub fn num_digits(i: usize) -> u8 { f64::log10(i as f64) as u8 + 1 } + +#[derive(Debug, Clone)] +pub struct Pos { + pub row: usize, + pub col: usize, + pub offset: usize, +} + +impl Pos { + pub fn new(row: usize, col: usize, offset: usize) -> Self { + Self { row, col, offset } + } +} diff --git a/tests/input.rs b/tests/input.rs index 0e48fce..836c84f 100644 --- a/tests/input.rs +++ b/tests/input.rs @@ -55,7 +55,11 @@ fn test_input_all_combinations_sanity() { for input in inputs { t.input(input.clone()); + t.undo(); + t.redo(); t.input_without_shortcuts(input); + t.undo(); + t.redo(); } } diff --git a/tests/textarea.rs b/tests/textarea.rs index 841333f..e762a50 100644 --- a/tests/textarea.rs +++ b/tests/textarea.rs @@ -830,7 +830,6 @@ fn test_delete_str_multiple_lines() { assert!(t.undo(), "undo: {:?}", test); assert_eq!(t.lines(), before, "content after undo: {:?}", test); - assert_eq!(t.cursor(), (row, col), "cursor after undo: {:?}", test); } }