diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index c3c349d..988c903 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -67,3 +67,15 @@ pub struct Undo; #[derive(PartialEq, Clone, Default, Action)] #[action(namespace = editor)] pub struct Redo; + +#[derive(PartialEq, Clone, Default, Action)] +#[action(namespace = editor)] +pub struct Cut; + +#[derive(PartialEq, Clone, Default, Action)] +#[action(namespace = editor)] +pub struct Copy; + +#[derive(PartialEq, Clone, Default, Action)] +#[action(namespace = editor)] +pub struct Paste; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 84410e9..354eb48 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -8,7 +8,7 @@ mod tests; pub use actions::*; use gpui::{ - App, Bounds, CursorStyle, Entity, EntityInputHandler, FocusHandle, Focusable, + App, Bounds, ClipboardItem, CursorStyle, Entity, EntityInputHandler, FocusHandle, Focusable, InteractiveElement, MouseDownEvent, MouseMoveEvent, Pixels, Point, UTF16Selection, Window, prelude::*, }; @@ -450,6 +450,94 @@ impl Editor { cx.notify(); } } + + pub fn cut(&mut self, _: &Cut, _window: &mut Window, cx: &mut Context) { + let range = self.selection.range(); + let text = self + .buffer + .update(cx, |buffer, _| buffer.slice(range.clone())); + cx.write_to_clipboard(ClipboardItem::new_string(text)); + + if !self.selection.is_empty() { + let selection_before = self.selection; + let selection_after = Selection::cursor(self.selection.start); + let transaction_id = self.buffer.update(cx, |buffer, _| { + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.remove(tx, range); + }) + }); + + self.selection = selection_after; + if let Some(transaction) = self + .undo_stack + .iter_mut() + .find(|entry| entry.id == transaction_id) + { + transaction.selection_after = selection_after; + } else { + self.undo_stack.push_back(Transaction { + id: transaction_id, + selection_before, + selection_after, + }); + self.redo_stack.clear(); + } + cx.notify(); + } + } + + pub fn copy(&mut self, _: &Copy, _window: &mut Window, cx: &mut Context) { + let text = self.buffer.update(cx, |buffer, _| { + if self.selection.is_cursor() { + let row = buffer.offset_to_point(self.selection.start).row; + let line_start = buffer.point_to_offset(TextPoint { row, column: 0 }); + let line_end = buffer.point_to_offset(TextPoint { + row, + column: buffer.line_len(row), + }); + let mut line = buffer.slice(line_start..line_end); + if !line.ends_with('\n') { + line.push('\n'); + } + line + } else { + buffer.slice(self.selection.range()) + } + }); + + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + + pub fn paste(&mut self, _: &Paste, _window: &mut Window, cx: &mut Context) { + if let Some(item) = cx.read_from_clipboard() { + let text = item.text().unwrap_or_default(); + let range = self.selection.range(); + let selection_before = self.selection; + let selection_after = Selection::cursor(range.start + text.len()); + let transaction_id = self.buffer.update(cx, |buffer, _| { + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.replace(tx, range.clone(), &text); + }) + }); + + self.selection = selection_after; + if let Some(transaction) = self + .undo_stack + .iter_mut() + .find(|entry| entry.id == transaction_id) + { + transaction.selection_after = selection_after; + } else { + self.undo_stack.push_back(Transaction { + id: transaction_id, + selection_before, + selection_after, + }); + self.redo_stack.clear(); + } + cx.notify(); + } + } } impl Render for Editor { @@ -508,6 +596,15 @@ impl Render for Editor { .on_action(cx.listener(|editor, action: &Redo, window, cx| { editor.redo(action, window, cx); })) + .on_action(cx.listener(|editor, action: &Cut, window, cx| { + editor.cut(action, window, cx); + })) + .on_action(cx.listener(|editor, action: &Copy, window, cx| { + editor.copy(action, window, cx); + })) + .on_action(cx.listener(|editor, action: &Paste, window, cx| { + editor.paste(action, window, cx); + })) .child(EditorElement::new(cx.entity().clone())) } } diff --git a/crates/editor/src/tests.rs b/crates/editor/src/tests.rs index dd5dc18..244aa23 100644 --- a/crates/editor/src/tests.rs +++ b/crates/editor/src/tests.rs @@ -2,10 +2,10 @@ mod context; pub use context::EditorTestContext; -use gpui::TestAppContext; +use gpui::{ClipboardItem, TestAppContext}; use indoc::indoc; -use crate::{Redo, Undo}; +use crate::{Copy, Cut, Paste, Redo, Undo}; use buffer::{Buffer, FormatSpan, Selection}; /// Returns the effective formatting at an offset. @@ -702,3 +702,175 @@ fn test_undo_redo_multiple(cx: &mut TestAppContext) { jumps over the lazy dog "}); } + +#[gpui::test] +fn test_cut(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state(indoc! {" + The «quickˇ» brown fox + jumps over the lazy dog + "}); + cx.update_editor(|editor, window, cx| { + editor.cut(&Cut, window, cx); + }); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| item.text()) + .unwrap(); + assert_eq!(clipboard_text, "quick"); + cx.assert_editor_state(indoc! {" + The ˇ brown fox + jumps over the lazy dog + "}); +} + +#[gpui::test] +fn test_copy(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state(indoc! {" + The «quickˇ» brown fox + jumps over the lazy dog + "}); + cx.update_editor(|editor, window, cx| { + editor.copy(&Copy, window, cx); + }); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| item.text()) + .unwrap(); + assert_eq!(clipboard_text, "quick"); + cx.assert_editor_state(indoc! {" + The «quickˇ» brown fox + jumps over the lazy dog + "}); +} + +#[gpui::test] +fn test_copy_line(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state("The quick ˇbrown fox\njumps over the lazy dog"); + cx.update_editor(|editor, window, cx| { + editor.copy(&Copy, window, cx); + }); + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| item.text()) + .unwrap(); + assert_eq!(clipboard_text, "The quick brown fox\n"); + + // Last line + cx.update_editor(|editor, window, cx| { + editor.change_selections(window, cx, |s| { + *s = Selection::cursor(35); + }); + }); + cx.assert_editor_state("The quick brown fox\njumps over the ˇlazy dog"); + cx.update_editor(|editor, window, cx| { + editor.copy(&Copy, window, cx); + }); + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| item.text()) + .unwrap(); + assert_eq!(clipboard_text, "jumps over the lazy dog\n"); +} + +#[gpui::test] +fn test_paste(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.write_to_clipboard(ClipboardItem::new_string("brown ".to_string())); + cx.set_state("The quick ˇfox"); + cx.update_editor(|editor, window, cx| { + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state("The quick brown ˇfox"); +} + +#[gpui::test] +fn test_paste_replace_selection(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.write_to_clipboard(ClipboardItem::new_string("brown".to_string())); + cx.set_state("The quick «blueˇ» fox"); + cx.update_editor(|editor, window, cx| { + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state("The quick brownˇ fox"); +} + +#[gpui::test] +fn test_cut_and_paste(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state(indoc! {" + The quick «brownˇ» fox + jumps over the lazy dog + "}); + cx.update_editor(|editor, window, cx| { + editor.cut(&Cut, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick ˇ fox + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.paste(&Paste, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brownˇ fox + jumps over the lazy dog + "}); +} + +#[gpui::test] +fn test_undo_redo_cut(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state("The quick «brownˇ» fox"); + cx.update_editor(|editor, window, cx| { + editor.cut(&Cut, window, cx); + }); + cx.assert_editor_state("The quick ˇ fox"); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state("The quick «brownˇ» fox"); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state("The quick ˇ fox"); +} + +#[gpui::test] +fn test_undo_redo_paste(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.write_to_clipboard(ClipboardItem::new_string("brown ".to_string())); + + cx.set_state("The quick ˇfox"); + cx.update_editor(|editor, window, cx| { + editor.paste(&Paste, window, cx); + }); + cx.assert_editor_state("The quick brown ˇfox"); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state("The quick ˇfox"); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state("The quick brown ˇfox"); +} diff --git a/crates/editor/src/tests/context.rs b/crates/editor/src/tests/context.rs index 7bf9353..dacb823 100644 --- a/crates/editor/src/tests/context.rs +++ b/crates/editor/src/tests/context.rs @@ -1,4 +1,4 @@ -use gpui::{Context, Entity, TestAppContext, VisualTestContext, Window}; +use gpui::{ClipboardItem, Context, Entity, TestAppContext, VisualTestContext, Window}; use std::{ops::Deref, time::Instant}; use buffer::Selection; @@ -91,4 +91,12 @@ impl EditorTestContext { self.editor .update_in(&mut self.cx, |editor, window, cx| f(editor, window, cx)) } + + pub fn read_from_clipboard(&self) -> Option { + self.cx.read_from_clipboard() + } + + pub fn write_to_clipboard(&mut self, item: ClipboardItem) { + self.cx.write_to_clipboard(item); + } } diff --git a/crates/ryuk/src/main.rs b/crates/ryuk/src/main.rs index 17311ac..950b9eb 100644 --- a/crates/ryuk/src/main.rs +++ b/crates/ryuk/src/main.rs @@ -4,8 +4,8 @@ use gpui::{ }; use editor::{ - Backspace, Delete, DeleteToBeginningOfLine, DeleteToEndOfLine, MoveDown, MoveLeft, MoveRight, - MoveUp, Newline, Redo, ToggleBold, ToggleItalic, ToggleUnderline, Undo, + Backspace, Copy, Cut, Delete, DeleteToBeginningOfLine, DeleteToEndOfLine, MoveDown, MoveLeft, + MoveRight, MoveUp, Newline, Paste, Redo, ToggleBold, ToggleItalic, ToggleUnderline, Undo, }; use workspace::Workspace; @@ -35,6 +35,10 @@ fn main() { KeyBinding::new("ctrl-k", DeleteToEndOfLine, None), KeyBinding::new("cmd-z", Undo, None), KeyBinding::new("cmd-shift-z", Redo, None), + // Clipboard + KeyBinding::new("cmd-x", Cut, None), + KeyBinding::new("cmd-c", Copy, None), + KeyBinding::new("cmd-v", Paste, None), // Format KeyBinding::new("cmd-b", ToggleBold, None), KeyBinding::new("cmd-i", ToggleItalic, None),