1
0
зеркало из https://github.com/glebtv/tui-textarea.git synced 2026-09-07 07:55:50 +03:00

refactor undo/redo implementation

Этот коммит содержится в:
rhysd
2023-11-13 19:29:43 +09:00
родитель cb868a19b6
Коммит bae9d4cee3
6 изменённых файлов: 203 добавлений и 164 удалений

Просмотреть файл

@@ -158,22 +158,22 @@ impl<'a> LineHighlighter<'a> {
pub fn selection( pub fn selection(
&mut self, &mut self,
row: usize, current_row: usize,
start_row: usize, start_row: usize,
start_off: usize, start_off: usize,
end_row: usize, end_row: usize,
end_off: usize, end_off: usize,
) { ) {
let (start, end) = if row == start_row { let (start, end) = if current_row == start_row {
if start_row == end_row { if start_row == end_row {
(start_off, end_off) (start_off, end_off)
} else { } else {
self.select_at_end = true; self.select_at_end = true;
(start_off, self.line.len()) (start_off, self.line.len())
} }
} else if row == end_row { } else if current_row == end_row {
(0, end_off) (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; self.select_at_end = true;
(0, self.line.len()) (0, self.line.len())
} else { } else {

Просмотреть файл

@@ -1,69 +1,75 @@
use crate::util::Pos;
use std::collections::VecDeque; use std::collections::VecDeque;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum EditKind { pub enum EditKind {
InsertChar(char, usize), InsertChar(char),
DeleteChar(char, usize), DeleteChar(char),
InsertNewline(usize), InsertNewline,
DeleteNewline(usize), DeleteNewline,
InsertStr(String, usize), InsertStr(String),
DeleteStr(String, usize), DeleteStr(String),
InsertChunk(Vec<String>, usize, usize), InsertChunk(Vec<String>),
DeleteChunk(Vec<String>, usize, usize), DeleteChunk(Vec<String>),
} }
impl EditKind { impl EditKind {
pub(crate) fn apply(&self, row: usize, lines: &mut Vec<String>) { pub(crate) fn apply(&self, lines: &mut Vec<String>, before: &Pos, after: &Pos) {
match self { match self {
EditKind::InsertChar(c, i) => { EditKind::InsertChar(c) => {
lines[row].insert(*i, *c); lines[before.row].insert(before.offset, *c);
} }
EditKind::DeleteChar(_, i) => { EditKind::DeleteChar(_) => {
lines[row].remove(*i); lines[before.row].remove(after.offset);
} }
EditKind::InsertNewline(i) => { EditKind::InsertNewline => {
let line = &mut lines[row]; let line = &mut lines[before.row];
let next_line = line[*i..].to_string(); let next_line = line[before.offset..].to_string();
line.truncate(*i); line.truncate(before.offset);
lines.insert(row + 1, next_line); lines.insert(before.row + 1, next_line);
} }
EditKind::DeleteNewline(_) => { EditKind::DeleteNewline => {
if row > 0 { debug_assert!(before.row > 0, "invalid pos: {:?}", before);
let line = lines.remove(row); let line = lines.remove(before.row);
lines[row - 1].push_str(&line); lines[before.row - 1].push_str(&line);
}
} }
EditKind::InsertStr(s, i) => { EditKind::InsertStr(s) => {
lines[row].insert_str(*i, s.as_str()); lines[before.row].insert_str(before.offset, s.as_str());
} }
EditKind::DeleteStr(s, i) => { EditKind::DeleteStr(s) => {
let end = *i + s.len(); lines[after.row].drain(after.offset..after.offset + s.len());
lines[row].replace_range(*i..end, "");
} }
EditKind::InsertChunk(c, row, i) => { EditKind::InsertChunk(c) => {
debug_assert!(c.len() > 1, "Chunk size must be > 1: {:?}", c); debug_assert!(c.len() > 1, "Chunk size must be > 1: {:?}", c);
let row = *row;
// Handle first line of chunk // Handle first line of chunk
let first_line = &mut lines[row]; let first_line = &mut lines[before.row];
let mut last_line = first_line.drain(*i..).as_str().to_string(); let mut last_line = first_line.drain(before.offset..).as_str().to_string();
first_line.push_str(&c[0]); first_line.push_str(&c[0]);
// Handle last line of chunk // Handle last line of chunk
let next_row = before.row + 1;
last_line.insert_str(0, c.last().unwrap()); 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 // Handle middle lines of chunk
lines.splice(row + 1..row + 1, c[1..c.len() - 1].iter().cloned()); 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); debug_assert!(c.len() > 1, "Chunk size must be > 1: {:?}", c);
let row = *row;
lines[row].truncate(*i); // Remove middle lines of chunk
let mut last_line = lines.drain(row + 1..row + c.len()).last().unwrap(); 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()); 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 { fn invert(&self) -> Self {
use EditKind::*; use EditKind::*;
match self.clone() { match self.clone() {
InsertChar(c, i) => DeleteChar(c, i), InsertChar(c) => DeleteChar(c),
DeleteChar(c, i) => InsertChar(c, i), DeleteChar(c) => InsertChar(c),
InsertNewline(i) => DeleteNewline(i), InsertNewline => DeleteNewline,
DeleteNewline(i) => InsertNewline(i), DeleteNewline => InsertNewline,
InsertStr(s, i) => DeleteStr(s, i), InsertStr(s) => DeleteStr(s),
DeleteStr(s, i) => InsertStr(s, i), DeleteStr(s) => InsertStr(s),
InsertChunk(c, r, i) => DeleteChunk(c, r, i), InsertChunk(c) => DeleteChunk(c),
DeleteChunk(c, r, i) => InsertChunk(c, r, i), DeleteChunk(c) => InsertChunk(c),
} }
} }
} }
@@ -86,39 +92,33 @@ impl EditKind {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Edit { pub struct Edit {
kind: EditKind, kind: EditKind,
cursor_before: (usize, usize), before: Pos,
cursor_after: (usize, usize), after: Pos,
} }
impl Edit { impl Edit {
pub fn new( pub fn new(kind: EditKind, before: Pos, after: Pos) -> Self {
kind: EditKind,
cursor_before: (usize, usize),
cursor_after: (usize, usize),
) -> Self {
Self { Self {
kind, kind,
cursor_before, before,
cursor_after, after,
} }
} }
pub fn redo(&self, lines: &mut Vec<String>) { pub fn redo(&self, lines: &mut Vec<String>) {
let (row, _) = self.cursor_before; self.kind.apply(lines, &self.before, &self.after);
self.kind.apply(row, lines);
} }
pub fn undo(&self, lines: &mut Vec<String>) { pub fn undo(&self, lines: &mut Vec<String>) {
let (row, _) = self.cursor_after; self.kind.invert().apply(lines, &self.after, &self.before); // Undo is redo of inverted edit
self.kind.invert().apply(row, lines); // Undo is redo of inverted edit
} }
pub fn cursor_before(&self) -> (usize, usize) { pub fn cursor_before(&self) -> (usize, usize) {
self.cursor_before (self.before.row, self.before.col)
} }
pub fn cursor_after(&self) -> (usize, usize) { pub fn cursor_after(&self) -> (usize, usize) {
self.cursor_after (self.after.row, self.after.col)
} }
} }
@@ -185,7 +185,6 @@ mod tests {
#[test] #[test]
fn insert_delete_chunk() { fn insert_delete_chunk() {
#[rustfmt::skip] #[rustfmt::skip]
#[allow(clippy::identity_op)]
let tests = [ let tests = [
// Positions // Positions
( (
@@ -195,7 +194,7 @@ mod tests {
"cd", "cd",
"ef", "ef",
][..], ][..],
// (row, offset) position before edit // (row, col) position before edit
(0, 0), (0, 0),
// Chunk to be inserted // 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 { for test in tests {
let (row, offset) = pos; 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 mut lines: Vec<_> = before.iter().map(|s| s.to_string()).collect();
let chunk: Vec<_> = input.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); let edit = EditKind::InsertChunk(chunk.clone());
edit.apply(row, &mut lines); edit.apply(&mut lines, &before_pos, &after_pos);
assert_eq!( assert_eq!(&lines, expected, "{test:?}");
&lines, expected,
"{:?} at {:?} with {:?}",
before, pos, input,
);
let edit = EditKind::DeleteChunk(chunk, row, offset); let edit = EditKind::DeleteChunk(chunk);
edit.apply(row, &mut lines); edit.apply(&mut lines, &after_pos, &before_pos);
assert_eq!( assert_eq!(&lines, &before, "{test:?}");
&lines, &before,
"{:?} at {:?} with {:?}",
before, pos, input,
);
} }
} }
} }

Просмотреть файл

@@ -8,7 +8,7 @@ use crate::ratatui::widgets::{Block, Widget};
use crate::scroll::Scrolling; use crate::scroll::Scrolling;
#[cfg(feature = "search")] #[cfg(feature = "search")]
use crate::search::Search; use crate::search::Search;
use crate::util::spaces; use crate::util::{spaces, Pos};
use crate::widget::{Renderer, Viewport}; use crate::widget::{Renderer, Viewport};
use crate::word::{find_word_end_forward, find_word_start_backward}; use crate::word::{find_word_end_forward, find_word_start_backward};
#[cfg(feature = "ratatui")] #[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. /// A type to manage state of textarea.
/// ///
/// [`TextArea::default`] creates an empty textarea. [`TextArea::new`] creates a textarea with given text lines. /// [`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)) { fn push_history(&mut self, kind: EditKind, before: Pos, after_offset: usize) {
let edit = Edit::new(kind, cursor_before, self.cursor); let (row, col) = self.cursor;
let after = Pos::new(row, col, after_offset);
let edit = Edit::new(kind, before, after);
self.history.push(edit); self.history.push(edit);
} }
@@ -745,7 +735,11 @@ impl<'a> TextArea<'a> {
.unwrap_or(line.len()); .unwrap_or(line.len());
line.insert(i, c); line.insert(i, c);
self.cursor.1 += 1; 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. /// 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) .nth(col)
.map(|(i, _)| i) .map(|(i, _)| i)
.unwrap_or(line.len()); .unwrap_or(line.len());
let before = Pos::new(row, col, i);
self.cursor = ( let (row, col) = (
row + chunk.len() - 1, row + chunk.len() - 1,
chunk[chunk.len() - 1].chars().count(), chunk[chunk.len() - 1].chars().count(),
); );
self.cursor = (row, col);
let edit = EditKind::InsertChunk(chunk, row, i); let end_offset = chunk.last().unwrap().len();
edit.apply(row, &mut self.lines);
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 true
} }
@@ -812,45 +810,37 @@ impl<'a> TextArea<'a> {
.map(|(i, _)| i) .map(|(i, _)| i)
.unwrap_or(line.len()); .unwrap_or(line.len());
line.insert_str(i, &s); line.insert_str(i, &s);
let end_offset = i + s.len();
self.cursor.1 += s.chars().count(); 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 true
} }
fn delete_offset_range( fn delete_range(&mut self, start: Pos, end: Pos, should_yank: bool) {
&mut self, self.cursor = (start.row, start.col);
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);
if start_row == end_row { if start.row == end.row {
let removed = self.lines[start_row] let removed = self.lines[start.row]
.drain(start_offset..end_offset) .drain(start.offset..end.offset)
.as_str() .as_str()
.to_string(); .to_string();
if should_yank { if should_yank {
self.yank = removed.clone().into(); 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; return;
} }
let mut deleted = vec![self.lines[start_row] let mut deleted = vec![self.lines[start.row]
.drain(start_offset..) .drain(start.offset..)
.as_str() .as_str()
.to_string()]; .to_string()];
deleted.extend(self.lines.drain(start_row + 1..end_row)); deleted.extend(self.lines.drain(start.row + 1..end.row));
if start_row + 1 < self.lines.len() { if start.row + 1 < self.lines.len() {
let mut last_line = self.lines.remove(start_row + 1); let mut last_line = self.lines.remove(start.row + 1);
self.lines[start_row].push_str(&last_line[end_offset..]); self.lines[start.row].push_str(&last_line[end.offset..]);
last_line.truncate(end_offset); last_line.truncate(end.offset);
deleted.push(last_line); deleted.push(last_line);
} }
@@ -859,11 +849,11 @@ impl<'a> TextArea<'a> {
} }
let edit = if deleted.len() == 1 { let edit = if deleted.len() == 1 {
EditKind::DeleteStr(deleted.remove(0), start_offset) EditKind::DeleteStr(deleted.remove(0))
} else { } 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 /// 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 remaining = chars;
let mut find_end = move |line: &str| { let mut find_end = move |line: &str| {
let mut col = 0usize;
for (i, _) in line.char_indices() { for (i, _) in line.char_indices() {
if remaining == 0 { if remaining == 0 {
return Some(i); return Some((i, col));
} }
col += 1;
remaining -= 1; remaining -= 1;
} }
if remaining == 0 { if remaining == 0 {
Some(line.len()) Some((line.len(), col))
} else { } else {
remaining -= 1; remaining -= 1;
None None
@@ -919,29 +911,39 @@ impl<'a> TextArea<'a> {
}; };
// First line // 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] let removed = self.lines[start_row]
.drain(start_offset..start_offset + offset) .drain(start_offset..end_offset)
.as_str() .as_str()
.to_string(); .to_string();
self.yank = removed.clone().into(); 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; return true;
} }
let mut r = start_row + 1; let mut r = start_row + 1;
let mut i = 0; let mut offset = 0;
let mut col = 0;
while r < self.lines.len() { while r < self.lines.len() {
let line = &self.lines[r]; let line = &self.lines[r];
if let Some(offset) = find_end(line) { if let Some((o, c)) = find_end(line) {
i = offset; offset = o;
col = c;
break; break;
} }
r += 1; 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 true
} }
@@ -950,20 +952,31 @@ impl<'a> TextArea<'a> {
return false; return false;
} }
let cursor_before = self.cursor; #[inline]
let row = cursor_before.0; 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]; let line = &mut self.lines[row];
if let Some((i, _)) = line.char_indices().nth(col) { if let Some((i, _)) = line.char_indices().nth(col) {
let bytes = line[i..] let (bytes, chars) = bytes_and_chars(chars, &line[i..]);
.char_indices() let removed = line.drain(i..i + bytes).as_str().to_string();
.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, "");
self.cursor = (row, col); 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(); self.yank = removed.into();
true true
} else { } else {
@@ -1021,17 +1034,17 @@ impl<'a> TextArea<'a> {
let (row, col) = self.cursor; let (row, col) = self.cursor;
let line = &mut self.lines[row]; let line = &mut self.lines[row];
let idx = line let offset = line
.char_indices() .char_indices()
.nth(col) .nth(col)
.map(|(i, _)| i) .map(|(i, _)| i)
.unwrap_or(line.len()); .unwrap_or(line.len());
let next_line = line[idx..].to_string(); let next_line = line[offset..].to_string();
line.truncate(idx); line.truncate(offset);
self.lines.insert(row + 1, next_line); self.lines.insert(row + 1, next_line);
self.cursor = (row + 1, 0); 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 /// 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; return true;
} }
let (row, col) = self.cursor; let (row, _) = self.cursor;
if row == 0 { if row == 0 {
return false; return false;
} }
@@ -1061,7 +1074,7 @@ impl<'a> TextArea<'a> {
self.cursor = (row - 1, prev_line.chars().count()); self.cursor = (row - 1, prev_line.chars().count());
prev_line.push_str(&line); 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 true
} }
@@ -1088,10 +1101,14 @@ impl<'a> TextArea<'a> {
} }
let line = &mut self.lines[row]; let line = &mut self.lines[row];
if let Some((i, c)) = line.char_indices().nth(col - 1) { if let Some((offset, c)) = line.char_indices().nth(col - 1) {
line.remove(i); line.remove(offset);
self.cursor.1 -= 1; 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 true
} else { } else {
false false
@@ -1427,7 +1444,7 @@ impl<'a> TextArea<'a> {
fn delete_selection(&mut self, should_yank: bool) -> bool { fn delete_selection(&mut self, should_yank: bool) -> bool {
if let Some((s, e)) = self.take_selection_range() { 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; return true;
} }
false false

Просмотреть файл

@@ -6,3 +6,16 @@ pub fn spaces(size: u8) -> &'static str {
pub fn num_digits(i: usize) -> u8 { pub fn num_digits(i: usize) -> u8 {
f64::log10(i as f64) as u8 + 1 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 }
}
}

Просмотреть файл

@@ -55,7 +55,11 @@ fn test_input_all_combinations_sanity() {
for input in inputs { for input in inputs {
t.input(input.clone()); t.input(input.clone());
t.undo();
t.redo();
t.input_without_shortcuts(input); t.input_without_shortcuts(input);
t.undo();
t.redo();
} }
} }

Просмотреть файл

@@ -830,7 +830,6 @@ fn test_delete_str_multiple_lines() {
assert!(t.undo(), "undo: {:?}", test); assert!(t.undo(), "undo: {:?}", test);
assert_eq!(t.lines(), before, "content after undo: {:?}", test); assert_eq!(t.lines(), before, "content after undo: {:?}", test);
assert_eq!(t.cursor(), (row, col), "cursor after undo: {:?}", test);
} }
} }