add ^Y and kill-buffer to textarea (#5075)

## Summary
- add a kill buffer to the text area and wire Ctrl+Y to yank it
- capture text from Ctrl+W, Ctrl+U, and Ctrl+K operations into the kill
buffer
- add regression coverage ensuring the last kill can be yanked back

Fixes #5017


------
https://chatgpt.com/codex/tasks/task_i_68e95bf06c48832cbf3d2ba8fa2035d2
This commit is contained in:
Jeremy Rose
2025-10-15 14:39:55 -07:00
committed by GitHub
parent 995f5c3614
commit 17550fee9e

View File

@@ -26,6 +26,7 @@ pub(crate) struct TextArea {
wrap_cache: RefCell<Option<WrapCache>>,
preferred_col: Option<usize>,
elements: Vec<TextElement>,
kill_buffer: String,
}
#[derive(Debug, Clone)]
@@ -48,6 +49,7 @@ impl TextArea {
wrap_cache: RefCell::new(None),
preferred_col: None,
elements: Vec::new(),
kill_buffer: String::new(),
}
}
@@ -57,6 +59,7 @@ impl TextArea {
self.wrap_cache.replace(None);
self.preferred_col = None;
self.elements.clear();
self.kill_buffer.clear();
}
pub fn text(&self) -> &str {
@@ -305,6 +308,13 @@ impl TextArea {
} => {
self.kill_to_end_of_line();
}
KeyEvent {
code: KeyCode::Char('y'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.yank();
}
// Cursor movement
KeyEvent {
@@ -437,7 +447,7 @@ impl TextArea {
pub fn delete_backward_word(&mut self) {
let start = self.beginning_of_previous_word();
self.replace_range(start..self.cursor_pos, "");
self.kill_range(start..self.cursor_pos);
}
/// Delete text to the right of the cursor using "word" semantics.
@@ -448,32 +458,63 @@ impl TextArea {
pub fn delete_forward_word(&mut self) {
let end = self.end_of_next_word();
if end > self.cursor_pos {
self.replace_range(self.cursor_pos..end, "");
self.kill_range(self.cursor_pos..end);
}
}
pub fn kill_to_end_of_line(&mut self) {
let eol = self.end_of_current_line();
if self.cursor_pos == eol {
let range = if self.cursor_pos == eol {
if eol < self.text.len() {
self.replace_range(self.cursor_pos..eol + 1, "");
Some(self.cursor_pos..eol + 1)
} else {
None
}
} else {
self.replace_range(self.cursor_pos..eol, "");
Some(self.cursor_pos..eol)
};
if let Some(range) = range {
self.kill_range(range);
}
}
pub fn kill_to_beginning_of_line(&mut self) {
let bol = self.beginning_of_current_line();
if self.cursor_pos == bol {
if bol > 0 {
self.replace_range(bol - 1..bol, "");
}
let range = if self.cursor_pos == bol {
if bol > 0 { Some(bol - 1..bol) } else { None }
} else {
self.replace_range(bol..self.cursor_pos, "");
Some(bol..self.cursor_pos)
};
if let Some(range) = range {
self.kill_range(range);
}
}
pub fn yank(&mut self) {
if self.kill_buffer.is_empty() {
return;
}
let text = self.kill_buffer.clone();
self.insert_str(&text);
}
fn kill_range(&mut self, range: Range<usize>) {
let range = self.expand_range_to_element_boundaries(range);
if range.start >= range.end {
return;
}
let removed = self.text[range.clone()].to_string();
if removed.is_empty() {
return;
}
self.kill_buffer = removed;
self.replace_range_raw(range, "");
}
/// Move the cursor left by a single grapheme cluster.
pub fn move_cursor_left(&mut self) {
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos);
@@ -1198,6 +1239,39 @@ mod tests {
assert_eq!(t.cursor(), elem_range.start);
}
#[test]
fn yank_restores_last_kill() {
let mut t = ta_with("hello");
t.set_cursor(0);
t.kill_to_end_of_line();
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
t.yank();
assert_eq!(t.text(), "hello");
assert_eq!(t.cursor(), 5);
let mut t = ta_with("hello world");
t.set_cursor(t.text().len());
t.delete_backward_word();
assert_eq!(t.text(), "hello ");
assert_eq!(t.cursor(), 6);
t.yank();
assert_eq!(t.text(), "hello world");
assert_eq!(t.cursor(), 11);
let mut t = ta_with("hello");
t.set_cursor(5);
t.kill_to_beginning_of_line();
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
t.yank();
assert_eq!(t.text(), "hello");
assert_eq!(t.cursor(), 5);
}
#[test]
fn cursor_left_and_right_handle_graphemes() {
let mut t = ta_with("a👍b");