From 2a447d45c2d47d24c9999deefc380485b4210637 Mon Sep 17 00:00:00 2001 From: rhysd Date: Wed, 1 Nov 2023 20:15:42 +0900 Subject: [PATCH 1/7] add `EditKind::InsertChunks` and `EditKind::DeleteChunk` edit kinds --- src/history.rs | 414 +++++++++++++++++++++++++++++++++++++++++++++++- src/textarea.rs | 54 +++++-- 2 files changed, 451 insertions(+), 17 deletions(-) diff --git a/src/history.rs b/src/history.rs index 3fd3ae8..a58ed03 100644 --- a/src/history.rs +++ b/src/history.rs @@ -6,12 +6,14 @@ pub enum EditKind { DeleteChar(char, usize), InsertNewline(usize), DeleteNewline(usize), - Insert(String, usize), - Remove(String, usize), + InsertStr(String, usize), + DeleteStr(String, usize), + InsertChunk(Vec, usize), + DeleteChunk(Vec, usize), } impl EditKind { - fn apply(&self, row: usize, lines: &mut Vec) { + pub(crate) fn apply(&self, row: usize, lines: &mut Vec) { match self { EditKind::InsertChar(c, i) => { lines[row].insert(*i, *c); @@ -31,13 +33,36 @@ impl EditKind { lines[row - 1].push_str(&line); } } - EditKind::Insert(s, i) => { + EditKind::InsertStr(s, i) => { lines[row].insert_str(*i, s.as_str()); } - EditKind::Remove(s, i) => { + EditKind::DeleteStr(s, i) => { let end = *i + s.len(); lines[row].replace_range(*i..end, ""); } + EditKind::InsertChunk(c, i) => { + debug_assert!(c.len() > 1, "Chunk size must be > 1: {:?}", c); + + // Handle first line of chunk + let first_line = &mut lines[row]; + let mut last_line = first_line.drain(*i..).as_str().to_string(); + first_line.push_str(&c[0]); + + // Handle last line of chunk + last_line.insert_str(0, c.last().unwrap()); + lines.insert(row + 1, last_line); + + // Handle last line of chunk + lines.splice(row + 1..row + 1, c[1..c.len() - 1].iter().cloned()); + } + EditKind::DeleteChunk(c, i) => { + debug_assert!(c.len() > 1, "Chunk size must be > 1: {:?}", c); + + lines[row].truncate(*i); + let mut last_line = lines.drain(row + 1..row + c.len()).last().unwrap(); + last_line.drain(..c[c.len() - 1].len()); + lines[row].push_str(&last_line); + } } } @@ -48,8 +73,10 @@ impl EditKind { DeleteChar(c, i) => InsertChar(c, i), InsertNewline(i) => DeleteNewline(i), DeleteNewline(i) => InsertNewline(i), - Insert(s, i) => Remove(s, i), - Remove(s, i) => Insert(s, i), + InsertStr(s, i) => DeleteStr(s, i), + DeleteStr(s, i) => InsertStr(s, i), + InsertChunk(c, i) => DeleteChunk(c, i), + DeleteChunk(c, i) => InsertChunk(c, i), } } } @@ -148,3 +175,376 @@ impl History { self.max_items } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_delete_chunk() { + #[rustfmt::skip] + let tests = [ + // Positions + ( + // Text before edit + &[ + "ab", + "cd", + "ef", + ][..], + // (row, offset) position before edit + (0, 0), + // Chunk to be inserted + &[ + "x", "y", + ][..], + // Text after edit + &[ + "x", + "yab", + "cd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 1), + &[ + "x", "y", + ][..], + &[ + "ax", + "yb", + "cd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 2), + &[ + "x", "y", + ][..], + &[ + "abx", + "y", + "cd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 0), + &[ + "x", "y", + ][..], + &[ + "ab", + "x", + "ycd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 1), + &[ + "x", "y", + ][..], + &[ + "ab", + "cx", + "yd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 2), + &[ + "x", "y", + ][..], + &[ + "ab", + "cdx", + "y", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (2, 0), + &[ + "x", "y", + ][..], + &[ + "ab", + "cd", + "x", + "yef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (2, 1), + &[ + "x", "y", + ][..], + &[ + "ab", + "cd", + "ex", + "yf", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (2, 2), + &[ + "x", "y", + ][..], + &[ + "ab", + "cd", + "efx", + "y", + ][..], + ), + // More than 2 lines + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 1), + &[ + "x", "y", "z", "w" + ][..], + &[ + "ab", + "cx", + "y", + "z", + "wd", + "ef", + ][..], + ), + // Empty lines + ( + &[ + "", + "", + "", + ][..], + (0, 0), + &[ + "x", "y", "z" + ][..], + &[ + "x", + "y", + "z", + "", + "", + ][..], + ), + ( + &[ + "", + "", + "", + ][..], + (1, 0), + &[ + "x", "y", "z" + ][..], + &[ + "", + "x", + "y", + "z", + "", + ][..], + ), + ( + &[ + "", + "", + "", + ][..], + (2, 0), + &[ + "x", "y", "z" + ][..], + &[ + "", + "", + "x", + "y", + "z", + ][..], + ), + // Empty buffer + ( + &[ + "", + ][..], + (0, 0), + &[ + "x", "y", "z" + ][..], + &[ + "x", + "y", + "z", + ][..], + ), + // Insert empty lines + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 0), + &[ + "", "", "", + ][..], + &[ + "", + "", + "ab", + "cd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 0), + &[ + "", "", "", + ][..], + &[ + "ab", + "", + "", + "cd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 1), + &[ + "", "", "", + ][..], + &[ + "ab", + "c", + "", + "d", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 2), + &[ + "", "", "", + ][..], + &[ + "ab", + "cd", + "", + "", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (2, 2), + &[ + "", "", "", + ][..], + &[ + "ab", + "cd", + "ef", + "", + "", + ][..], + ), + ]; + + for (before, pos, input, expected) in tests { + let (row, offset) = pos; + let mut lines: Vec<_> = before.iter().map(|s| s.to_string()).collect(); + let chunk: Vec<_> = input.iter().map(|s| s.to_string()).collect(); + + let edit = EditKind::InsertChunk(chunk.clone(), offset); + edit.apply(row, &mut lines); + assert_eq!( + &lines, expected, + "{:?} at {:?} with {:?}", + before, pos, input, + ); + + let edit = EditKind::DeleteChunk(chunk, offset); + edit.apply(row, &mut lines); + assert_eq!( + &lines, &before, + "{:?} at {:?} with {:?}", + before, pos, input, + ); + } + } +} diff --git a/src/textarea.rs b/src/textarea.rs index 2d4c620..eb6911c 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -13,6 +13,7 @@ use crate::widget::{Renderer, Viewport}; use crate::word::{find_word_end_forward, find_word_start_backward}; #[cfg(feature = "ratatui")] use ratatui::text::Line; +use std::mem; #[cfg(feature = "tuirs")] use tui::text::Spans as Line; use unicode_width::UnicodeWidthChar as _; @@ -612,8 +613,7 @@ impl<'a> TextArea<'a> { self.push_history(EditKind::InsertChar(c, i), (row, col)); } - /// Insert a string at current cursor position. Currently the string must not contain any newlines. 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. /// ``` /// use tui_textarea::TextArea; /// @@ -621,9 +621,43 @@ impl<'a> TextArea<'a> { /// /// textarea.insert_str("hello"); /// assert_eq!(textarea.lines(), ["hello"]); + /// + /// textarea.insert_str(", world\ngoodbye, world"); + /// assert_eq!(textarea.lines(), ["hello, world", "goodbye, world"]); /// ``` - pub fn insert_str>(&mut self, s: S) -> bool { - let s = s.into(); + pub fn insert_str(&mut self, s: &str) -> bool { + let mut lines: Vec<_> = s.lines().map(ToString::to_string).collect(); + match lines.len() { + 0 => false, + 1 => self.insert_piece(mem::take(&mut lines[0])), + _ => self.insert_chunk(lines), + } + } + + fn insert_chunk(&mut self, chunk: Vec) -> bool { + debug_assert!(chunk.len() > 1, "Chunk size must be > 1: {:?}", chunk); + + let (row, col) = self.cursor; + let line = &mut self.lines[row]; + let i = line + .char_indices() + .nth(col) + .map(|(i, _)| i) + .unwrap_or(line.len()); + + self.cursor = ( + row + chunk.len() - 1, + col + chunk[chunk.len() - 1].chars().count(), + ); + + let edit = EditKind::InsertChunk(chunk, i); + edit.apply(row, &mut self.lines); + + self.push_history(edit, (row, col)); + true + } + + fn insert_piece(&mut self, s: String) -> bool { if s.is_empty() { return false; } @@ -631,7 +665,7 @@ impl<'a> TextArea<'a> { let (row, col) = self.cursor; let line = &mut self.lines[row]; debug_assert!( - !line.contains('\n'), + !s.contains('\n'), "string given to insert_str must not contain newline: {:?}", line, ); @@ -644,7 +678,7 @@ impl<'a> TextArea<'a> { line.insert_str(i, &s); self.cursor.1 += s.chars().count(); - self.push_history(EditKind::Insert(s, i), (row, col)); + self.push_history(EditKind::InsertStr(s, i), (row, col)); true } @@ -676,7 +710,7 @@ impl<'a> TextArea<'a> { line.replace_range(i..i + bytes, ""); self.cursor = (row, col); - self.push_history(EditKind::Remove(removed.clone(), i), cursor_before); + self.push_history(EditKind::DeleteStr(removed.clone(), i), cursor_before); self.yank = removed; true } else { @@ -704,7 +738,7 @@ impl<'a> TextArea<'a> { } if self.hard_tab_indent { - return self.insert_str("\t"); + return self.insert_piece("\t".to_string()); } let (row, col) = self.cursor; @@ -714,7 +748,7 @@ impl<'a> TextArea<'a> { .map(|c| c.width().unwrap_or(0)) .sum(); let len = self.tab_len - (width % self.tab_len as usize) as u8; - self.insert_str(spaces(len)) + self.insert_piece(spaces(len).to_string()) } /// Insert a newline at current cursor position. @@ -937,7 +971,7 @@ impl<'a> TextArea<'a> { /// assert_eq!(textarea.lines(), [" bbb cccaaa"]); /// ``` pub fn paste(&mut self) -> bool { - self.insert_str(self.yank.to_string()) + self.insert_piece(self.yank.to_string()) } /// Move the cursor to the position specified by the [`CursorMove`] parameter. For each kind of cursor moves, see From 8bc9ff4dba8f4f2010a621234ad1c267665cd7be Mon Sep 17 00:00:00 2001 From: rhysd Date: Wed, 1 Nov 2023 21:27:59 +0900 Subject: [PATCH 2/7] add tests for `TextArea::insert_str` --- src/textarea.rs | 9 +- tests/textarea.rs | 361 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 365 insertions(+), 5 deletions(-) diff --git a/src/textarea.rs b/src/textarea.rs index eb6911c..e7a078c 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -13,7 +13,6 @@ use crate::widget::{Renderer, Viewport}; use crate::word::{find_word_end_forward, find_word_start_backward}; #[cfg(feature = "ratatui")] use ratatui::text::Line; -use std::mem; #[cfg(feature = "tuirs")] use tui::text::Spans as Line; use unicode_width::UnicodeWidthChar as _; @@ -625,11 +624,11 @@ impl<'a> TextArea<'a> { /// textarea.insert_str(", world\ngoodbye, world"); /// assert_eq!(textarea.lines(), ["hello, world", "goodbye, world"]); /// ``` - pub fn insert_str(&mut self, s: &str) -> bool { - let mut lines: Vec<_> = s.lines().map(ToString::to_string).collect(); + pub fn insert_str>(&mut self, s: S) -> bool { + let mut lines: Vec<_> = s.as_ref().lines().map(ToString::to_string).collect(); match lines.len() { 0 => false, - 1 => self.insert_piece(mem::take(&mut lines[0])), + 1 => self.insert_piece(lines.remove(0)), _ => self.insert_chunk(lines), } } @@ -647,7 +646,7 @@ impl<'a> TextArea<'a> { self.cursor = ( row + chunk.len() - 1, - col + chunk[chunk.len() - 1].chars().count(), + chunk[chunk.len() - 1].chars().count(), ); let edit = EditKind::InsertChunk(chunk, i); diff --git a/tests/textarea.rs b/tests/textarea.rs index e9adac6..99b3fe0 100644 --- a/tests/textarea.rs +++ b/tests/textarea.rs @@ -24,3 +24,364 @@ fn test_insert_soft_tab() { assert_eq!(line, expected, "{:?}", test); } } + +#[test] +fn test_insert_str_one_line() { + for i in 0..="ab".len() { + let mut t = TextArea::from(["ab"]); + t.move_cursor(CursorMove::Jump(0, i as u16)); + assert!(t.insert_str("x"), "{}", i); + let have = &t.lines()[0]; + + let mut want = "ab".to_string(); + want.insert(i, 'x'); + assert_eq!(&want, have, "{}", i); + } +} + +#[test] +fn test_insert_str_empty_line() { + let mut t = TextArea::from(["ab"]); + assert!(!t.insert_str("")); + assert_eq!(t.lines(), ["ab"]); +} + +#[test] +fn test_insert_str_multiple_lines() { + #[rustfmt::skip] + let tests = [ + // Positions + ( + // Text before edit + &[ + "ab", + "cd", + "ef", + ][..], + // (row, offset) position before edit + (0, 0), + // String to be inserted + "x\ny", + // (row, offset) position after edit + (1, 1), + // Text after edit + &[ + "x", + "yab", + "cd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 1), + "x\ny", + (1, 1), + &[ + "ax", + "yb", + "cd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 2), + "x\ny", + (1, 1), + &[ + "abx", + "y", + "cd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 0), + "x\ny", + (2, 1), + &[ + "ab", + "x", + "ycd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 1), + "x\ny", + (2, 1), + &[ + "ab", + "cx", + "yd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 2), + "x\ny", + (2, 1), + &[ + "ab", + "cdx", + "y", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (2, 0), + "x\ny", + (3, 1), + &[ + "ab", + "cd", + "x", + "yef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (2, 1), + "x\ny", + (3, 1), + &[ + "ab", + "cd", + "ex", + "yf", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (2, 2), + "x\ny", + (3, 1), + &[ + "ab", + "cd", + "efx", + "y", + ][..], + ), + // More than 2 lines + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 1), + "x\ny\nz\nw", + (4, 1), + &[ + "ab", + "cx", + "y", + "z", + "wd", + "ef", + ][..], + ), + // Empty lines + ( + &[ + "", + "", + "", + ][..], + (0, 0), + "x\ny\nz", + (2, 1), + &[ + "x", + "y", + "z", + "", + "", + ][..], + ), + ( + &[ + "", + "", + "", + ][..], + (1, 0), + "x\ny\nz", + (3, 1), + &[ + "", + "x", + "y", + "z", + "", + ][..], + ), + ( + &[ + "", + "", + "", + ][..], + (2, 0), + "x\ny\nz", + (4, 1), + &[ + "", + "", + "x", + "y", + "z", + ][..], + ), + // Empty buffer + ( + &[ + "", + ][..], + (0, 0), + "x\ny\nz", + (2, 1), + &[ + "x", + "y", + "z", + ][..], + ), + // Insert empty lines + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 0), + "\n\n\n", + (2, 0), + &[ + "", + "", + "ab", + "cd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 0), + "\n\n\n", + (3, 0), + &[ + "ab", + "", + "", + "cd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 1), + "\n\n\n", + (3, 0), + &[ + "ab", + "c", + "", + "d", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 2), + "\n\n\n", + (3, 0), + &[ + "ab", + "cd", + "", + "", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (2, 2), + "\n\n\n", + (4, 0), + &[ + "ab", + "cd", + "ef", + "", + "", + ][..], + ), + ]; + + for test in tests { + let (before, before_pos, input, after_pos, expected) = test; + + let mut t = TextArea::from(before.iter().map(|s| s.to_string())); + let (row, col) = before_pos; + t.move_cursor(CursorMove::Jump(row, col)); + + assert!(t.insert_str(input), "{:?}", test); + assert_eq!(t.cursor(), after_pos, "{:?}", test); + assert_eq!(t.lines(), expected, "{:?}", test); + } +} From b324ccd9fd662a9e497289d17bf00048413e7145 Mon Sep 17 00:00:00 2001 From: rhysd Date: Thu, 2 Nov 2023 09:26:50 +0900 Subject: [PATCH 3/7] allow `TextArea::delete_str` to delete across newlines --- src/textarea.rs | 98 ++++++++++++-- tests/textarea.rs | 318 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 405 insertions(+), 11 deletions(-) diff --git a/src/textarea.rs b/src/textarea.rs index e7a078c..665e845 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -681,17 +681,93 @@ impl<'a> TextArea<'a> { true } - /// Delete a string in current cursor line. The `chars` parameter means number of characters, not a byte length of - /// the string. This method returns if some text was deleted or not in the textarea. + /// Delete a string from the current cursor position. The `chars` parameter means number of characters, not a byte + /// length of the string. Newlines at the end of lines are counted in the number. This method returns if some text + /// was deleted or not. /// ``` - /// use tui_textarea::TextArea; + /// use tui_textarea::{TextArea, CursorMove}; /// /// let mut textarea = TextArea::from(["🐱🐶🐰🐮"]); + /// textarea.move_cursor(CursorMove::Forward); /// - /// textarea.delete_str(1, 2); + /// textarea.delete_str(2); /// assert_eq!(textarea.lines(), ["🐱🐮"]); + /// + /// let mut textarea = TextArea::from(["🐱", "🐶", "🐰", "🐮"]); + /// textarea.move_cursor(CursorMove::Down); + /// + /// textarea.delete_str(4); // Deletes 🐶, \n, 🐰, \n + /// assert_eq!(textarea.lines(), ["🐱", "🐮"]); /// ``` - pub fn delete_str(&mut self, col: usize, chars: usize) -> bool { + pub fn delete_str(&mut self, chars: usize) -> bool { + if chars == 0 { + return false; + } + + let (row, col) = self.cursor; + + let mut remaining = chars; + let mut find_end = move |line: &str| { + for (i, _) in line.char_indices() { + if remaining == 0 { + return Some(i); + } + remaining -= 1; + } + if remaining == 0 { + Some(line.len()) + } else { + remaining -= 1; + None + } + }; + + let line = &self.lines[row]; + let first_start = { + line.char_indices() + .nth(col) + .map(|(i, _)| i) + .unwrap_or(line.len()) + }; + + // First line + if let Some(offset) = find_end(&line[first_start..]) { + let removed = self.lines[row] + .drain(first_start..first_start + offset) + .as_str() + .to_string(); + self.yank = removed.clone(); + self.push_history(EditKind::DeleteStr(removed, first_start), self.cursor); + return true; + } + + let mut r = row + 1; + let mut i = 0; + + while r < self.lines.len() { + let line = &self.lines[r]; + if let Some(offset) = find_end(line) { + i = offset; + break; + } + r += 1; + } + + let mut deleted = vec![self.lines[row].drain(first_start..).as_str().to_string()]; + deleted.extend(self.lines.drain(row + 1..r)); + if row + 1 < self.lines.len() { + let mut last_line = self.lines.remove(row + 1); + self.lines[row].push_str(&last_line[i..]); + last_line.truncate(i); + deleted.push(last_line); + } + + self.yank = deleted.join("\n"); // TODO + self.push_history(EditKind::DeleteChunk(deleted, first_start), self.cursor); + true + } + + fn delete_piece(&mut self, col: usize, chars: usize) -> bool { if chars == 0 { return false; } @@ -866,7 +942,7 @@ impl<'a> TextArea<'a> { /// assert_eq!(textarea.lines(), ["ab"]); /// ``` pub fn delete_line_by_end(&mut self) -> bool { - if self.delete_str(self.cursor.1, usize::MAX) { + if self.delete_piece(self.cursor.1, usize::MAX) { return true; } self.delete_next_char() // At the end of the line. Try to delete next line @@ -887,7 +963,7 @@ impl<'a> TextArea<'a> { /// assert_eq!(textarea.lines(), ["cde"]); /// ``` pub fn delete_line_by_head(&mut self) -> bool { - if self.delete_str(0, self.cursor.1) { + if self.delete_piece(0, self.cursor.1) { return true; } self.delete_newline() @@ -914,9 +990,9 @@ impl<'a> TextArea<'a> { pub fn delete_word(&mut self) -> bool { let (r, c) = self.cursor; if let Some(col) = find_word_start_backward(&self.lines[r], c) { - self.delete_str(col, c - col) + self.delete_piece(col, c - col) } else if c > 0 { - self.delete_str(0, c) + self.delete_piece(0, c) } else { self.delete_newline() } @@ -942,11 +1018,11 @@ impl<'a> TextArea<'a> { let (r, c) = self.cursor; let line = &self.lines[r]; if let Some(col) = find_word_end_forward(line, c) { - self.delete_str(c, col - c) + self.delete_piece(c, col - c) } else { let end_col = line.chars().count(); if c < end_col { - self.delete_str(c, end_col - c) + self.delete_piece(c, end_col - c) } else if r + 1 < self.lines.len() { self.cursor = (r + 1, 0); self.delete_newline() diff --git a/tests/textarea.rs b/tests/textarea.rs index 99b3fe0..f713663 100644 --- a/tests/textarea.rs +++ b/tests/textarea.rs @@ -37,6 +37,10 @@ fn test_insert_str_one_line() { want.insert(i, 'x'); assert_eq!(&want, have, "{}", i); } + + let mut t = TextArea::default(); + assert!(t.insert_str("x")); + assert_eq!(t.lines(), ["x"]); } #[test] @@ -385,3 +389,317 @@ fn test_insert_str_multiple_lines() { assert_eq!(t.lines(), expected, "{:?}", test); } } + +#[test] +fn test_delete_str_delete_nothing() { + for i in 0..="ab".len() { + let mut t = TextArea::from(["ab"]); + assert!(!t.delete_str(0), "{}", i); + } + let mut t = TextArea::default(); + assert!(!t.delete_str(0)); +} + +#[test] +fn test_delete_str_delete_within_line() { + for i in 0.."abc".len() { + for j in 1..="abc".len() - i { + let mut t = TextArea::from(["abc"]); + t.move_cursor(CursorMove::Jump(0, i as _)); + assert!(t.delete_str(j), "at {}, size={}", i, j); + let have = &t.lines()[0]; + + let mut want = "abc".to_string(); + want.drain(i..i + j); + assert_eq!(&want, have, "at {}, size={}", i, j); + } + } +} + +#[test] +fn test_delete_str_delete_multiple_lines() { + #[rustfmt::skip] + let tests = [ + // Length + ( + // Text before edit + &[ + "ab", + "cd", + "ef", + ][..], + // (row, offset) cursor position + (0, 0), + // Chars to be deleted + 3, + // Deleted text + "ab\n", + // Text after edit + &[ + "cd", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 0), + 4, + "ab\nc", + &[ + "d", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 0), + 5, + "ab\ncd", + &[ + "", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 0), + 6, + "ab\ncd\n", + &[ + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 0), + 7, + "ab\ncd\ne", + &[ + "f", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 0), + 8, + "ab\ncd\nef", + &[ + "", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 0), + 9, + "ab\ncd\nef", + &[ + "", + ][..], + ), + // Positions + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 1), + 3, + "b\nc", + &[ + "ad", + "ef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (0, 2), + 4, + "\ncd\n", + &[ + "abef", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (1, 0), + 4, + "cd\ne", + &[ + "ab", + "f", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (2, 0), + 3, + "ef", + &[ + "ab", + "cd", + "", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (2, 1), + 2, + "f", + &[ + "ab", + "cd", + "e", + ][..], + ), + ( + &[ + "ab", + "cd", + "ef", + ][..], + (2, 2), + 1, + "", + &[ + "ab", + "cd", + "ef", + ][..], + ), + // Empty lines + ( + &[ + "", + "", + "", + ][..], + (0, 0), + 1, + "\n", + &[ + "", + "", + ][..], + ), + ( + &[ + "", + "", + "", + ][..], + (0, 0), + 2, + "\n\n", + &[ + "", + ][..], + ), + ( + &[ + "", + "", + "", + ][..], + (0, 0), + 3, + "\n\n", + &[ + "", + ][..], + ), + ( + &[ + "", + "", + "", + ][..], + (1, 0), + 1, + "\n", + &[ + "", + "", + ][..], + ), + ( + &[ + "", + "", + "", + ][..], + (2, 0), + 1, + "", + &[ + "", + "", + "", + ][..], + ), + // Empty buffer + ( + &[ + "", + ][..], + (0, 0), + 1, + "", + &[ + "", + ][..], + ), + ]; + + for test in tests { + let (before, (row, col), chars, deleted, after) = test; + + let mut t = TextArea::from(before.iter().map(|s| s.to_string())); + t.move_cursor(CursorMove::Jump(row as _, col as _)); + + assert!(t.delete_str(chars), "did not modified: {:?}", test); + assert_eq!(t.cursor(), (row, col), "cursor position: {:?}", test); + assert_eq!(t.lines(), after, "text buffer content: {:?}", test); + assert_eq!(t.yank_text(), deleted, "yanked text: {:?}", test); + } +} From 9db8a3d31a2abb237339909f6011705facb742a7 Mon Sep 17 00:00:00 2001 From: rhysd Date: Thu, 2 Nov 2023 21:08:21 +0900 Subject: [PATCH 4/7] test `TextArea::undo` after `insert_str` and `delete_str` --- src/textarea.rs | 9 ++++++++- tests/textarea.rs | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/textarea.rs b/src/textarea.rs index 665e845..2008517 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -763,7 +763,14 @@ impl<'a> TextArea<'a> { } self.yank = deleted.join("\n"); // TODO - self.push_history(EditKind::DeleteChunk(deleted, first_start), self.cursor); + + let edit = if deleted.len() == 1 { + EditKind::DeleteStr(deleted.remove(0), first_start) + } else { + EditKind::DeleteChunk(deleted, first_start) + }; + self.push_history(edit, self.cursor); + true } diff --git a/tests/textarea.rs b/tests/textarea.rs index f713663..ddffa52 100644 --- a/tests/textarea.rs +++ b/tests/textarea.rs @@ -387,6 +387,11 @@ fn test_insert_str_multiple_lines() { assert!(t.insert_str(input), "{:?}", test); assert_eq!(t.cursor(), after_pos, "{:?}", test); assert_eq!(t.lines(), expected, "{:?}", test); + + assert!(t.undo(), "undo: {:?}", test); + assert_eq!(t.lines(), before, "content after undo: {:?}", test); + let before_pos = (row as _, col as _); + assert_eq!(t.cursor(), before_pos, "cursor after undo: {:?}", test); } } @@ -701,5 +706,9 @@ fn test_delete_str_delete_multiple_lines() { assert_eq!(t.cursor(), (row, col), "cursor position: {:?}", test); assert_eq!(t.lines(), after, "text buffer content: {:?}", test); assert_eq!(t.yank_text(), deleted, "yanked text: {:?}", test); + + assert!(t.undo(), "undo: {:?}", test); + assert_eq!(t.lines(), before, "content after undo: {:?}", test); + assert_eq!(t.cursor(), (row, col), "cursor after undo: {:?}", test); } } From 1283081f8278843b6064ac9f1ec49479e027992a Mon Sep 17 00:00:00 2001 From: rhysd Date: Thu, 2 Nov 2023 22:54:28 +0900 Subject: [PATCH 5/7] keep yanked lines in `Vec` --- src/textarea.rs | 46 +++++++++++++++++++++++++++++++++++++--------- tests/textarea.rs | 6 +++--- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/textarea.rs b/src/textarea.rs index 2008517..8270de5 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -17,6 +17,33 @@ use ratatui::text::Line; use tui::text::Spans as Line; use unicode_width::UnicodeWidthChar as _; +#[derive(Debug, Clone)] +enum YankText { + Piece(String), + Chunk(Vec), +} + +impl Default for YankText { + fn default() -> Self { + Self::Piece(String::new()) + } +} + +impl> From for YankText { + fn from(s: S) -> Self { + Self::Piece(s.into()) + } +} + +impl ToString for YankText { + fn to_string(&self) -> String { + match self { + Self::Piece(s) => s.clone(), + Self::Chunk(ss) => ss.join("\n"), + } + } +} + /// A type to manage state of textarea. /// /// [`TextArea::default`] creates an empty textarea. [`TextArea::new`] creates a textarea with given text lines. @@ -50,7 +77,7 @@ pub struct TextArea<'a> { line_number_style: Option