diff --git a/Cargo.lock b/Cargo.lock index df1070b..709ac5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -706,8 +706,8 @@ version = "0.0.0" dependencies = [ "anyhow", "gpui", + "ropey", "serde", - "text", ] [[package]] @@ -1465,7 +1465,6 @@ dependencies = [ "gpui", "indoc", "pretty_assertions", - "text", "ui", "util 0.0.0", ] @@ -5327,14 +5326,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "text" -version = "0.0.0" -dependencies = [ - "ropey", - "serde", -] - [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 41a8bab..2b12efe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,6 @@ members = [ "crates/editor", "crates/icons", "crates/ryuk", - "crates/text", "crates/ui", "crates/util", "crates/workspace", diff --git a/crates/buffer/Cargo.toml b/crates/buffer/Cargo.toml index 8fe4193..bb39da0 100644 --- a/crates/buffer/Cargo.toml +++ b/crates/buffer/Cargo.toml @@ -14,10 +14,10 @@ name = "buffer" path = "src/buffer.rs" [features] -test-support = ["text/test-support"] +test-support = [] [dependencies] anyhow = { workspace = true } gpui = { workspace = true } -text = { workspace = true } +ropey = { workspace = true } serde = { workspace = true } diff --git a/crates/buffer/src/buffer.rs b/crates/buffer/src/buffer.rs index 24d03e0..2bc578e 100644 --- a/crates/buffer/src/buffer.rs +++ b/crates/buffer/src/buffer.rs @@ -1,31 +1,91 @@ mod format_span; mod selection; +pub mod text; pub use format_span::*; pub use selection::*; +pub use text::*; -use std::ops::Range; +use std::{ + collections::VecDeque, + ops::Range, + time::{Duration, Instant}, +}; -use text::{TextBuffer, TextPoint}; +pub type TransactionId = usize; + +pub struct TransactionContext { + texts: Vec, + format: Option, +} + +#[derive(Clone, Debug)] +pub struct TextOperation { + pub range: Range, + pub before: String, + pub after: String, +} + +impl TextOperation { + pub fn invert(&self) -> Self { + TextOperation { + range: self.range.start..(self.range.start + self.after.len()), + before: self.after.clone(), + after: self.before.clone(), + } + } +} + +#[derive(Clone, Debug)] +pub enum FormatOperation { + ToggleBold(Range), + ToggleItalic(Range), + ToggleUnderline(Range), +} + +#[derive(Clone, Debug)] +pub enum TransactionKind { + Text(Vec), + Format(FormatOperation), +} + +#[derive(Clone, Debug)] +struct Transaction { + id: TransactionId, + timestamp: Instant, + kind: TransactionKind, +} #[derive(Clone, Debug)] pub struct Buffer { - text: TextBuffer, + text: Text, format_spans: Vec, + next_transaction_id: usize, + undo_stack: VecDeque, + redo_stack: VecDeque, + group_interval: Duration, } impl Buffer { pub fn new() -> Self { Self { - text: TextBuffer::new(), + text: Text::new(), format_spans: Vec::new(), + next_transaction_id: 0, + undo_stack: VecDeque::new(), + redo_stack: VecDeque::new(), + group_interval: Duration::from_millis(300), } } pub fn from_text(text: impl Into) -> Self { Self { - text: TextBuffer::from(text.into().as_str()), + text: Text::from(text.into().as_str()), format_spans: Vec::new(), + next_transaction_id: 0, + undo_stack: VecDeque::new(), + redo_stack: VecDeque::new(), + group_interval: Duration::from_millis(300), } } @@ -82,34 +142,49 @@ impl Buffer { &self.format_spans } - pub fn insert(&mut self, offset: usize, text: &str) { - let len = text.len(); + pub fn insert(&mut self, tx: &mut TransactionContext, offset: usize, text: &str) { + tx.texts.push(TextOperation { + range: offset..offset, + before: String::new(), + after: text.to_string(), + }); + self.text.insert(offset, text); - let delta = len as isize; - for span in &mut self.format_spans { - span.shift_by_delta(offset, delta); + let delta = text.len() as isize; + for format_span in &mut self.format_spans { + format_span.shift_by_delta(offset, delta); } } - pub fn remove(&mut self, range: Range) { - let len = range.len(); + pub fn remove(&mut self, tx: &mut TransactionContext, range: Range) { + tx.texts.push(TextOperation { + range: range.clone(), + before: self.text.slice(range.clone()).to_string(), + after: String::new(), + }); + self.text.remove(range.clone()); - let delta = -(len as isize); - for span in &mut self.format_spans { - span.shift_by_delta(range.start, delta); + let delta = -(range.len() as isize); + for format_span in &mut self.format_spans { + format_span.shift_by_delta(range.start, delta); } + self.format_spans.retain(|s| !s.range.is_empty()); + } - self.format_spans.retain(|span| !span.range.is_empty()); + pub fn replace(&mut self, tx: &mut TransactionContext, range: Range, text: &str) { + self.remove(tx, range.clone()); + self.insert(tx, range.start, text); } - pub fn replace(&mut self, range: Range, text: &str) { - self.remove(range.clone()); - self.insert(range.start, text); + pub fn toggle_bold(&mut self, tx: &mut TransactionContext, range: Range) { + tx.format = Some(FormatOperation::ToggleBold(range.clone())); + self.toggle_bold_unchecked(range); } - pub fn toggle_bold(&mut self, range: Range) { + /// Toggles bold without recording in transaction history. + fn toggle_bold_unchecked(&mut self, range: Range) { let is_fully_bold = self.is_formatted_with(&range, |span| span.bold); let should_split = |span: &FormatSpan| { @@ -159,7 +234,13 @@ impl Buffer { } } - pub fn toggle_italic(&mut self, range: Range) { + pub fn toggle_italic(&mut self, tx: &mut TransactionContext, range: Range) { + tx.format = Some(FormatOperation::ToggleItalic(range.clone())); + self.toggle_italic_unchecked(range); + } + + /// Toggles italic without recording in transaction history. + fn toggle_italic_unchecked(&mut self, range: Range) { let is_fully_italic = self.is_formatted_with(&range, |span| span.italic); let should_split = |span: &FormatSpan| { @@ -209,7 +290,13 @@ impl Buffer { } } - pub fn toggle_underline(&mut self, range: Range) { + pub fn toggle_underline(&mut self, tx: &mut TransactionContext, range: Range) { + tx.format = Some(FormatOperation::ToggleUnderline(range.clone())); + self.toggle_underline_unchecked(range); + } + + /// Toggles underline without recording in transaction history. + fn toggle_underline_unchecked(&mut self, range: Range) { let is_fully_underline = self.is_formatted_with(&range, |span| span.underline); let should_split = |span: &FormatSpan| { @@ -288,6 +375,131 @@ impl Buffer { }) .is_some_and(|cursor| cursor >= range.end) } + + pub fn transaction(&mut self, now: Instant, f: F) -> TransactionId + where + F: FnOnce(&mut Self, &mut TransactionContext), + { + let transaction_id = self.next_transaction_id; + let mut tx = TransactionContext { + texts: Vec::new(), + format: None, + }; + + f(self, &mut tx); + + if !tx.texts.is_empty() { + self.commit_transaction(TransactionKind::Text(tx.texts), now) + } else if let Some(format_operation) = tx.format { + self.commit_transaction(TransactionKind::Format(format_operation), now) + } else { + transaction_id + } + } + + fn commit_transaction(&mut self, kind: TransactionKind, now: Instant) -> TransactionId { + let transaction_id = self.next_transaction_id; + self.next_transaction_id += 1; + + if let TransactionKind::Text(ref new_text_operations) = kind + && let Some(last) = self.undo_stack.back_mut() + && now.saturating_duration_since(last.timestamp) < self.group_interval + && let TransactionKind::Text(ref mut last_text_operations) = last.kind + { + last_text_operations.extend_from_slice(new_text_operations); + last.timestamp = now; + self.redo_stack.clear(); + return last.id; + } + + self.undo_stack.push_back(Transaction { + id: transaction_id, + timestamp: now, + kind, + }); + self.redo_stack.clear(); + transaction_id + } + + fn exec_format_operation(&mut self, format_operation: &FormatOperation) { + match format_operation { + FormatOperation::ToggleBold(range) => self.toggle_bold_unchecked(range.clone()), + FormatOperation::ToggleItalic(range) => self.toggle_italic_unchecked(range.clone()), + FormatOperation::ToggleUnderline(range) => { + self.toggle_underline_unchecked(range.clone()) + } + } + } + + fn exec_text_operation(&mut self, text_operation: &TextOperation) { + if text_operation.before.is_empty() && !text_operation.after.is_empty() { + self.text + .insert(text_operation.range.start, &text_operation.after); + let delta = text_operation.after.len() as isize; + for span in &mut self.format_spans { + span.shift_by_delta(text_operation.range.start, delta); + } + } else if !text_operation.before.is_empty() && text_operation.after.is_empty() { + self.text.remove(text_operation.range.clone()); + let delta = -(text_operation.before.len() as isize); + for span in &mut self.format_spans { + span.shift_by_delta(text_operation.range.start, delta); + } + self.format_spans.retain(|s| !s.range.is_empty()); + } else { + self.text.remove(text_operation.range.clone()); + if !text_operation.after.is_empty() { + self.text + .insert(text_operation.range.start, &text_operation.after); + } + let delta = text_operation.after.len() as isize - text_operation.before.len() as isize; + for span in &mut self.format_spans { + span.shift_by_delta(text_operation.range.start, delta); + } + self.format_spans.retain(|span| !span.range.is_empty()); + } + } + + pub fn undo(&mut self) -> Option { + let tx = self.undo_stack.pop_back()?; + + match &tx.kind { + TransactionKind::Text(text_operations) => { + for text_operation in text_operations.iter().rev() { + self.exec_text_operation(&text_operation.invert()); + } + } + TransactionKind::Format(format_operation) => { + self.exec_format_operation(format_operation); + } + } + + self.redo_stack.push_back(tx.clone()); + Some(tx.id) + } + + pub fn redo(&mut self) -> Option { + let tx = self.redo_stack.pop_back()?; + + match &tx.kind { + TransactionKind::Text(text_operations) => { + for text_operation in text_operations { + self.exec_text_operation(text_operation); + } + } + TransactionKind::Format(format_operation) => { + self.exec_format_operation(format_operation); + } + } + + self.undo_stack.push_back(tx.clone()); + Some(tx.id) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_group_interval(&mut self, interval: Duration) { + self.group_interval = interval; + } } impl Default for Buffer { diff --git a/crates/text/src/text.rs b/crates/buffer/src/text.rs similarity index 95% rename from crates/text/src/text.rs rename to crates/buffer/src/text.rs index dcd910b..18c1d22 100644 --- a/crates/text/src/text.rs +++ b/crates/buffer/src/text.rs @@ -5,11 +5,11 @@ use std::{ }; #[derive(Clone, Debug)] -pub struct TextBuffer { +pub struct Text { rope: Rope, } -impl TextBuffer { +impl Text { pub fn new() -> Self { Self { rope: Rope::new() } } @@ -102,13 +102,13 @@ impl TextBuffer { } } -impl Default for TextBuffer { +impl Default for Text { fn default() -> Self { Self::new() } } -impl From<&str> for TextBuffer { +impl From<&str> for Text { fn from(text: &str) -> Self { Self { rope: Rope::from_str(text), @@ -116,7 +116,7 @@ impl From<&str> for TextBuffer { } } -impl Display for TextBuffer { +impl Display for Text { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { write!(f, "{}", self.rope) } diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 2bae424..4226e10 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -14,15 +14,15 @@ name = "editor" path = "src/editor.rs" [features] -test-support = ["text/test-support", "buffer/test-support", "gpui/test-support"] +test-support = ["buffer/test-support", "gpui/test-support"] [dependencies] buffer = { workspace = true } gpui = { workspace = true } -text = { workspace = true } ui = { workspace = true } [dev-dependencies] +buffer = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } indoc = { workspace = true } pretty_assertions = { workspace = true } diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 7968666..c3c349d 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -59,3 +59,11 @@ pub struct MoveLeft; #[derive(PartialEq, Clone, Default, Action)] #[action(namespace = editor)] pub struct MoveRight; + +#[derive(PartialEq, Clone, Default, Action)] +#[action(namespace = editor)] +pub struct Undo; + +#[derive(PartialEq, Clone, Default, Action)] +#[action(namespace = editor)] +pub struct Redo; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 36b2ddf..84410e9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -12,18 +12,26 @@ use gpui::{ InteractiveElement, MouseDownEvent, MouseMoveEvent, Pixels, Point, UTF16Selection, Window, prelude::*, }; -use std::ops::Range; +use std::{collections::VecDeque, ops::Range, time::Instant}; -use buffer::{Buffer, Selection, SelectionGoal}; -use text::TextPoint; +use buffer::{Buffer, Selection, SelectionGoal, TextPoint, TransactionId}; use crate::element::{EditorElement, PositionMap}; +#[derive(Clone, Debug)] +struct Transaction { + id: TransactionId, + selection_before: Selection, + selection_after: Selection, +} + pub struct Editor { focus_handle: FocusHandle, buffer: Entity, selection: Selection, marked_range: Option, + undo_stack: VecDeque, + redo_stack: VecDeque, } impl Editor { @@ -34,6 +42,8 @@ impl Editor { buffer, selection: Selection::cursor(0), marked_range: None, + undo_stack: VecDeque::new(), + redo_stack: VecDeque::new(), } } @@ -87,13 +97,29 @@ impl Editor { /// Inserts text at cursor, replacing any selected text. pub fn handle_input(&mut self, text: &str, _window: &mut Window, cx: &mut Context) { let range = self.selection.range(); - - self.buffer.update(cx, |buffer, _| { - buffer.replace(range.clone(), text); + 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); + }) }); - let new_offset = range.start + text.len(); - self.selection = Selection::cursor(new_offset); + 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(); } @@ -141,11 +167,30 @@ impl Editor { } if !self.selection.is_empty() { - self.buffer.update(cx, |buffer, _| { - buffer.remove(self.selection.range()); + let range = self.selection.range(); + let selection_before = 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::cursor(self.selection.start) + 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(); + } } self.selection.goal = SelectionGoal::None; @@ -177,11 +222,30 @@ impl Editor { } if !self.selection.is_empty() { - self.buffer.update(cx, |buffer, _| { - buffer.remove(self.selection.range()); + let range = self.selection.range(); + let selection_before = 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::cursor(self.selection.start) + 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(); + } } self.selection.goal = SelectionGoal::None; @@ -207,18 +271,32 @@ impl Editor { /// Inserts a newline character at the cursor position. pub fn newline(&mut self, _window: &mut Window, cx: &mut Context) { let selection = self.selection; - if !selection.is_empty() { - self.buffer.update(cx, |buffer, _| { - buffer.remove(selection.range()); - }); - } - - let cursor = selection.start; - self.buffer.update(cx, |buffer, _| { - buffer.insert(cursor, "\n"); + let selection_before = selection; + let selection_after = Selection::cursor(selection.start + 1); + let transaction_id = self.buffer.update(cx, |buffer, _| { + buffer.transaction(Instant::now(), |buffer, tx| { + if !selection.is_empty() { + buffer.remove(tx, selection.range()); + } + buffer.insert(tx, selection.start, "\n"); + }) }); - self.selection = Selection::cursor(cursor + 1); + 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(); + } self.selection.goal = SelectionGoal::None; cx.notify(); } @@ -286,10 +364,20 @@ impl Editor { return; } - self.buffer.update(cx, |buffer, _cx| { - buffer.toggle_bold(self.selection.range()); + let range = self.selection.range(); + let selection = self.selection; + let transaction_id = self.buffer.update(cx, |buffer, _cx| { + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.toggle_bold(tx, range.clone()); + }) }); + self.undo_stack.push_back(Transaction { + id: transaction_id, + selection_before: selection, + selection_after: selection, + }); + self.redo_stack.clear(); cx.notify(); } @@ -299,10 +387,20 @@ impl Editor { return; } - self.buffer.update(cx, |buffer, _cx| { - buffer.toggle_italic(self.selection.range()); + let range = self.selection.range(); + let selection = self.selection; + let transaction_id = self.buffer.update(cx, |buffer, _cx| { + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.toggle_italic(tx, range.clone()); + }) }); + self.undo_stack.push_back(Transaction { + id: transaction_id, + selection_before: selection, + selection_after: selection, + }); + self.redo_stack.clear(); cx.notify(); } @@ -312,12 +410,46 @@ impl Editor { return; } - self.buffer.update(cx, |buffer, _cx| { - buffer.toggle_underline(self.selection.range()); + let range = self.selection.range(); + let selection = self.selection; + let transaction_id = self.buffer.update(cx, |buffer, _cx| { + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.toggle_underline(tx, range.clone()); + }) }); + self.undo_stack.push_back(Transaction { + id: transaction_id, + selection_before: selection, + selection_after: selection, + }); + self.redo_stack.clear(); cx.notify(); } + + pub fn undo(&mut self, _: &Undo, _window: &mut Window, cx: &mut Context) { + if let Some(transaction) = self.undo_stack.pop_back() { + self.buffer.update(cx, |buffer, _| { + buffer.undo(); + }); + + self.selection = transaction.selection_before; + self.redo_stack.push_back(transaction); + cx.notify(); + } + } + + pub fn redo(&mut self, _: &Redo, _window: &mut Window, cx: &mut Context) { + if let Some(transaction) = self.redo_stack.pop_back() { + self.buffer.update(cx, |buffer, _| { + buffer.redo(); + }); + + self.selection = transaction.selection_after; + self.undo_stack.push_back(transaction); + cx.notify(); + } + } } impl Render for Editor { @@ -370,6 +502,12 @@ impl Render for Editor { .on_action(cx.listener(|editor, _action: &MoveRight, window, cx| { editor.move_right(window, cx); })) + .on_action(cx.listener(|editor, action: &Undo, window, cx| { + editor.undo(action, window, cx); + })) + .on_action(cx.listener(|editor, action: &Redo, window, cx| { + editor.redo(action, window, cx); + })) .child(EditorElement::new(cx.entity().clone())) } } @@ -455,12 +593,30 @@ impl EntityInputHandler for Editor { .map(|range| self.range_from_utf16(range, cx)) .unwrap_or_else(|| self.selection.range()); - self.buffer.update(cx, |buffer, _| { - buffer.replace(range.clone(), new_text); + let selection_before = self.selection; + let selection_after = Selection::cursor(range.start + new_text.len()); + let text = new_text.to_string(); + let transaction_id = self.buffer.update(cx, |buffer, _| { + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.replace(tx, range.clone(), &text); + }) }); - let new_cursor = range.start + new_text.len(); - self.selection = Selection::cursor(new_cursor); + 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(); + } let new_marked_range = if let Some(marked_range) = new_selection { let start = range.start + marked_range.start; diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index f281d76..ef2f84f 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -5,8 +5,7 @@ use gpui::{ }; use std::collections::BTreeSet; -use buffer::{Buffer, FormatSpan}; -use text::TextPoint; +use buffer::{Buffer, FormatSpan, TextPoint}; use crate::Editor; diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index a65cae3..d3a36b1 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -1,5 +1,4 @@ -use buffer::{Buffer, SelectionGoal}; -use text::TextPoint; +use buffer::{Buffer, SelectionGoal, TextPoint}; /// Move cursor left one character, wrapping to previous line if at start of line. pub fn left(buffer: &Buffer, offset: usize) -> Option { diff --git a/crates/editor/src/tests.rs b/crates/editor/src/tests.rs index 4e4d454..dd5dc18 100644 --- a/crates/editor/src/tests.rs +++ b/crates/editor/src/tests.rs @@ -5,8 +5,35 @@ pub use context::EditorTestContext; use gpui::TestAppContext; use indoc::indoc; +use crate::{Redo, Undo}; use buffer::{Buffer, FormatSpan, Selection}; +/// Returns the effective formatting at an offset. +fn formatting_at(buffer: &Buffer, offset: usize) -> FormatSpan { + let mut result = FormatSpan { + range: offset..offset, + bold: None, + italic: None, + underline: None, + }; + + for span in buffer.format_spans() { + if span.range.contains(&offset) { + if span.bold.is_some() { + result.bold = span.bold; + } + if span.italic.is_some() { + result.italic = span.italic; + } + if span.underline.is_some() { + result.underline = span.underline; + } + } + } + + result +} + #[gpui::test] fn test_backspace(cx: &mut TestAppContext) { let mut cx = EditorTestContext::new(cx); @@ -430,28 +457,248 @@ fn test_selection_movement_collapses(cx: &mut TestAppContext) { "}); } -/// Returns the effective formatting at an offset. -fn formatting_at(buffer: &Buffer, offset: usize) -> FormatSpan { - let mut result = FormatSpan { - range: offset..offset, - bold: None, - italic: None, - underline: None, - }; +#[gpui::test] +fn test_undo_redo_input(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); - for span in buffer.format_spans() { - if span.range.contains(&offset) { - if span.bold.is_some() { - result.bold = span.bold; - } - if span.italic.is_some() { - result.italic = span.italic; - } - if span.underline.is_some() { - result.underline = span.underline; - } - } - } + cx.set_state(indoc! {" + The quick brown foxˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\njumps over the lazy dog", window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown fox + jumps over the lazy dogˇ + "}); - result + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown foxˇ + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown fox + jumps over the lazy dogˇ + "}); +} + +#[gpui::test] +fn test_undo_redo_backspace(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.backspace(window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown fox + jumpˇ over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown fox + jumpsˇ over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown fox + jumpˇ over the lazy dog + "}); +} + +#[gpui::test] +fn test_undo_redo_delete(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state(indoc! {" + The quˇick brown fox + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.delete(window, cx); + }); + cx.assert_editor_state(indoc! {" + The quˇck brown fox + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quˇick brown fox + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quˇck brown fox + jumps over the lazy dog + "}); +} + +#[gpui::test] +fn test_undo_redo_selection(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.backspace(window, cx); + }); + cx.assert_editor_state(indoc! {" + The ˇ brown fox + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(window, cx, |s| { + *s = Selection::new(30, 34); + }); + }); + cx.assert_editor_state(indoc! {" + The brown fox + jumps over the «lazyˇ» dog + "}); + cx.update_editor(|editor, window, cx| { + editor.delete(window, cx); + }); + cx.assert_editor_state(indoc! {" + The brown fox + jumps over the ˇ dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The brown fox + jumps over the «lazyˇ» dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The «quickˇ» brown fox + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The ˇ brown fox + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The brown fox + jumps over the ˇ dog + "}); +} + +#[gpui::test] +fn test_undo_redo_multiple(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state(indoc! {" + ˇ + jumps over the lazy dog + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("The quick", window, cx); + }); + cx.assert_editor_state(indoc! {" + The quickˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.handle_input(" brown", window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brownˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quickˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.handle_input(" brown fox", window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown foxˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quickˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + ˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + ˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quickˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown foxˇ + jumps over the lazy dog + "}); } diff --git a/crates/editor/src/tests/context.rs b/crates/editor/src/tests/context.rs index 12c2095..7bf9353 100644 --- a/crates/editor/src/tests/context.rs +++ b/crates/editor/src/tests/context.rs @@ -1,5 +1,5 @@ use gpui::{Context, Entity, TestAppContext, VisualTestContext, Window}; -use std::ops::Deref; +use std::{ops::Deref, time::Instant}; use buffer::Selection; use util::test::{generate_marked_text, marked_text_ranges}; @@ -16,6 +16,14 @@ impl EditorTestContext { let window = cx.add_window(|_, cx| Editor::new(cx)); let editor = window.root(cx).unwrap(); + // Disable transaction grouping for deterministic test behavior + // Each operation should be a separate transaction for testing undo/redo + editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |buffer, _| { + buffer.set_group_interval(std::time::Duration::ZERO); + }); + }); + Self { cx: VisualTestContext::from_window(*window.deref(), cx), editor, @@ -28,7 +36,9 @@ impl EditorTestContext { self.editor.update_in(&mut self.cx, |editor, window, cx| { editor.buffer().update(cx, |buffer, _| { - buffer.replace(0..buffer.len(), &unmarked_text); + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.replace(tx, 0..buffer.len(), &unmarked_text); + }); }); if let Some(range) = selection_ranges.first() { diff --git a/crates/ryuk/src/main.rs b/crates/ryuk/src/main.rs index be56499..17311ac 100644 --- a/crates/ryuk/src/main.rs +++ b/crates/ryuk/src/main.rs @@ -5,7 +5,7 @@ use gpui::{ use editor::{ Backspace, Delete, DeleteToBeginningOfLine, DeleteToEndOfLine, MoveDown, MoveLeft, MoveRight, - MoveUp, Newline, ToggleBold, ToggleItalic, ToggleUnderline, + MoveUp, Newline, Redo, ToggleBold, ToggleItalic, ToggleUnderline, Undo, }; use workspace::Workspace; @@ -33,6 +33,8 @@ fn main() { KeyBinding::new("delete", Delete, None), KeyBinding::new("cmd-delete", DeleteToEndOfLine, None), KeyBinding::new("ctrl-k", DeleteToEndOfLine, None), + KeyBinding::new("cmd-z", Undo, None), + KeyBinding::new("cmd-shift-z", Redo, None), // Format KeyBinding::new("cmd-b", ToggleBold, None), KeyBinding::new("cmd-i", ToggleItalic, None), diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml deleted file mode 100644 index 6e8aec8..0000000 --- a/crates/text/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "text" -version.workspace = true -edition.workspace = true -publish.workspace = true -license.workspace = true -authors.workspace = true - -[lints] -workspace = true - -[lib] -name = "text" -path = "src/text.rs" - -[features] -test-support = [] - -[dependencies] -ropey = { workspace = true } -serde = { workspace = true }