Skip to content

Commit ff5501d

Browse files
Fix: Undo and redo correctly updates editor modified status (#244)
* Set an index for the last saved change I added an index that represents the last saved change. Editors are considered to be unsaved or modified if the current change is different from the save index. In other words, if the last saved change is `5`, undoing or redoing past that change should indicate that the editor has been modified. This is needed to fix two bugs in COSMIC Edit: * pop-os/cosmic-edit#116 * pop-os/cosmic-edit#128 * Unit test that confirms pivot logic works I'll most likely simplify the API as end users don't have a way to cleanly use `Pivot::Exact` without access to the internal command buffer. * Simplify save point API * Implement more save point unit tests A unit test for an edge case currently fails but normal usage works. * Fix edge case for empty command index and pivot * More save point unit tests for common use cases
1 parent b086769 commit ff5501d

File tree

2 files changed

+295
-10
lines changed

2 files changed

+295
-10
lines changed

src/edit/vi.rs

+68-10
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,39 @@ fn finish_change<'buffer, E: Edit<'buffer>>(
3131
editor: &mut E,
3232
commands: &mut cosmic_undo_2::Commands<Change>,
3333
changed: &mut bool,
34+
pivot: Option<usize>,
3435
) -> Option<Change> {
3536
//TODO: join changes together
3637
match editor.finish_change() {
3738
Some(change) => {
3839
if !change.items.is_empty() {
3940
commands.push(change.clone());
40-
*changed = true;
41+
*changed = eval_changed(commands, pivot);
4142
}
4243
Some(change)
4344
}
4445
None => None,
4546
}
4647
}
4748

49+
/// Evaluate if an [`ViEditor`] changed based on its last saved state.
50+
fn eval_changed(commands: &cosmic_undo_2::Commands<Change>, pivot: Option<usize>) -> bool {
51+
// Editors are considered modified if the current change index is unequal to the last
52+
// saved index or if `pivot` is None.
53+
// The latter case handles a never saved editor with a current command index of None.
54+
// Check the unit tests for an example.
55+
match (commands.current_command_index(), pivot) {
56+
(Some(current), Some(pivot)) => current != pivot,
57+
// Edge case for an editor with neither a save point nor any changes.
58+
// This could be a new editor or an editor without a save point where undo() is called
59+
// until the editor is fresh.
60+
(None, None) => false,
61+
// Default to true because it's safer to assume a buffer has been modified so as to not
62+
// lose changes
63+
_ => true,
64+
}
65+
}
66+
4867
fn search<'buffer, E: Edit<'buffer>>(editor: &mut E, value: &str, forwards: bool) -> bool {
4968
let mut cursor = editor.cursor();
5069
let start_line = cursor.line;
@@ -167,6 +186,7 @@ pub struct ViEditor<'syntax_system, 'buffer> {
167186
search_opt: Option<(String, bool)>,
168187
commands: cosmic_undo_2::Commands<Change>,
169188
changed: bool,
189+
save_pivot: Option<usize>,
170190
}
171191

172192
impl<'syntax_system, 'buffer> ViEditor<'syntax_system, 'buffer> {
@@ -179,6 +199,7 @@ impl<'syntax_system, 'buffer> ViEditor<'syntax_system, 'buffer> {
179199
search_opt: None,
180200
commands: cosmic_undo_2::Commands::new(),
181201
changed: false,
202+
save_pivot: None,
182203
}
183204
}
184205

@@ -233,6 +254,20 @@ impl<'syntax_system, 'buffer> ViEditor<'syntax_system, 'buffer> {
233254
self.changed = changed;
234255
}
235256

257+
/// Set current change as the save (or pivot) point.
258+
///
259+
/// A pivot point is the last saved index. Anything before or after the pivot indicates that
260+
/// the editor has been changed or is unsaved.
261+
///
262+
/// Undoing changes down to the pivot point sets the editor as unchanged.
263+
/// Redoing changes up to the pivot point sets the editor as unchanged.
264+
///
265+
/// Undoing or redoing changes beyond the pivot point sets the editor to changed.
266+
pub fn save_point(&mut self) {
267+
self.save_pivot = Some(self.commands.current_command_index().unwrap_or_default());
268+
self.changed = false;
269+
}
270+
236271
/// Set passthrough mode (true will turn off vi features)
237272
pub fn set_passthrough(&mut self, passthrough: bool) {
238273
if passthrough != self.passthrough {
@@ -251,19 +286,17 @@ impl<'syntax_system, 'buffer> ViEditor<'syntax_system, 'buffer> {
251286
log::debug!("Redo");
252287
for action in self.commands.redo() {
253288
undo_2_action(&mut self.editor, action);
254-
//TODO: clear changed flag when back to last saved state?
255-
self.changed = true;
256289
}
290+
self.changed = eval_changed(&self.commands, self.save_pivot);
257291
}
258292

259293
/// Undo a change
260294
pub fn undo(&mut self) {
261295
log::debug!("Undo");
262296
for action in self.commands.undo() {
263297
undo_2_action(&mut self.editor, action);
264-
//TODO: clear changed flag when back to last saved state?
265-
self.changed = true;
266298
}
299+
self.changed = eval_changed(&self.commands, self.save_pivot);
267300
}
268301

269302
#[cfg(feature = "swash")]
@@ -550,7 +583,12 @@ impl<'syntax_system, 'buffer> Edit<'buffer> for ViEditor<'syntax_system, 'buffer
550583
}
551584

552585
fn finish_change(&mut self) -> Option<Change> {
553-
finish_change(&mut self.editor, &mut self.commands, &mut self.changed)
586+
finish_change(
587+
&mut self.editor,
588+
&mut self.commands,
589+
&mut self.changed,
590+
self.save_pivot,
591+
)
554592
}
555593

556594
fn action(&mut self, font_system: &mut FontSystem, action: Action) {
@@ -564,7 +602,12 @@ impl<'syntax_system, 'buffer> Edit<'buffer> for ViEditor<'syntax_system, 'buffer
564602
if self.passthrough {
565603
editor.action(font_system, action);
566604
// Always finish change when passing through (TODO: group changes)
567-
finish_change(editor, &mut self.commands, &mut self.changed);
605+
finish_change(
606+
editor,
607+
&mut self.commands,
608+
&mut self.changed,
609+
self.save_pivot,
610+
);
568611
return;
569612
}
570613

@@ -589,7 +632,12 @@ impl<'syntax_system, 'buffer> Edit<'buffer> for ViEditor<'syntax_system, 'buffer
589632
log::debug!("Pass through action {:?}", action);
590633
editor.action(font_system, action);
591634
// Always finish change when passing through (TODO: group changes)
592-
finish_change(editor, &mut self.commands, &mut self.changed);
635+
finish_change(
636+
editor,
637+
&mut self.commands,
638+
&mut self.changed,
639+
self.save_pivot,
640+
);
593641
return;
594642
}
595643
};
@@ -620,7 +668,12 @@ impl<'syntax_system, 'buffer> Edit<'buffer> for ViEditor<'syntax_system, 'buffer
620668
return;
621669
}
622670
Event::ChangeFinish => {
623-
finish_change(editor, &mut self.commands, &mut self.changed);
671+
finish_change(
672+
editor,
673+
&mut self.commands,
674+
&mut self.changed,
675+
self.save_pivot,
676+
);
624677
return;
625678
}
626679
Event::Delete => Action::Delete,
@@ -687,7 +740,12 @@ impl<'syntax_system, 'buffer> Edit<'buffer> for ViEditor<'syntax_system, 'buffer
687740
}
688741
}
689742
}
690-
finish_change(editor, &mut self.commands, &mut self.changed);
743+
finish_change(
744+
editor,
745+
&mut self.commands,
746+
&mut self.changed,
747+
self.save_pivot,
748+
);
691749
}
692750
return;
693751
}

tests/editor_modified_state.rs

+227
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
#![cfg(feature = "vi")]
2+
3+
use std::sync::OnceLock;
4+
5+
use cosmic_text::{Buffer, Cursor, Edit, Metrics, SyntaxEditor, SyntaxSystem, ViEditor};
6+
7+
static SYNTAX_SYSTEM: OnceLock<SyntaxSystem> = OnceLock::new();
8+
9+
// New editor for tests
10+
fn editor() -> ViEditor<'static, 'static> {
11+
// More or less copied from cosmic-edit
12+
let font_size: f32 = 14.0;
13+
let line_height = (font_size * 1.4).ceil();
14+
15+
let metrics = Metrics::new(font_size, line_height);
16+
let buffer = Buffer::new_empty(metrics);
17+
let editor = SyntaxEditor::new(
18+
buffer,
19+
SYNTAX_SYSTEM.get_or_init(SyntaxSystem::new),
20+
"base16-eighties.dark",
21+
)
22+
.expect("Default theme `base16-eighties.dark` should be found");
23+
24+
ViEditor::new(editor)
25+
}
26+
27+
// Tests that inserting into an empty editor correctly sets the editor as modified.
28+
#[test]
29+
fn insert_in_empty_editor_sets_changed() {
30+
let mut editor = editor();
31+
32+
assert!(!editor.changed());
33+
editor.start_change();
34+
editor.insert_at(Cursor::new(0, 0), "Robert'); DROP TABLE Students;--", None);
35+
editor.finish_change();
36+
assert!(editor.changed());
37+
}
38+
39+
// Tests an edge case where a save point is never set.
40+
// Undoing changes should set the editor back to unmodified.
41+
#[test]
42+
fn insert_and_undo_in_unsaved_editor_is_unchanged() {
43+
let mut editor = editor();
44+
45+
assert!(!editor.changed());
46+
editor.start_change();
47+
editor.insert_at(Cursor::new(0, 0), "loop {}", None);
48+
editor.finish_change();
49+
assert!(editor.changed());
50+
51+
// Undoing the above change should set the editor as unchanged even if the save state is unset
52+
editor.start_change();
53+
editor.undo();
54+
editor.finish_change();
55+
assert!(!editor.changed());
56+
}
57+
58+
#[test]
59+
fn undo_to_save_point_sets_editor_to_unchanged() {
60+
let mut editor = editor();
61+
62+
// Latest saved change is the first change
63+
editor.start_change();
64+
let cursor = editor.insert_at(Cursor::new(0, 0), "Ferris is Rust's ", None);
65+
editor.finish_change();
66+
assert!(
67+
editor.changed(),
68+
"Editor should be set to changed after insertion"
69+
);
70+
editor.save_point();
71+
assert!(
72+
!editor.changed(),
73+
"Editor should be set to unchanged after setting a save point"
74+
);
75+
76+
// A new insert should set the editor as modified and the pivot should still be on the first
77+
// change from earlier
78+
editor.start_change();
79+
editor.insert_at(cursor, "mascot", None);
80+
editor.finish_change();
81+
assert!(
82+
editor.changed(),
83+
"Editor should be set to changed after inserting text after a save point"
84+
);
85+
86+
// Undoing the latest change should set the editor to unmodified again
87+
editor.start_change();
88+
editor.undo();
89+
editor.finish_change();
90+
assert!(
91+
!editor.changed(),
92+
"Editor should be set to unchanged after undoing to save point"
93+
);
94+
}
95+
96+
#[test]
97+
fn redoing_to_save_point_sets_editor_as_unchanged() {
98+
let mut editor = editor();
99+
100+
// Initial change
101+
assert!(
102+
!editor.changed(),
103+
"Editor should start in an unchanged state"
104+
);
105+
editor.start_change();
106+
editor.insert_at(Cursor::new(0, 0), "editor.start_change();", None);
107+
editor.finish_change();
108+
assert!(
109+
editor.changed(),
110+
"Editor should be set as modified after insert() and finish_change()"
111+
);
112+
editor.save_point();
113+
assert!(
114+
!editor.changed(),
115+
"Editor should be unchanged after setting a save point"
116+
);
117+
118+
// Change to undo then redo
119+
editor.start_change();
120+
editor.insert_at(Cursor::new(1, 0), "editor.finish_change()", None);
121+
editor.finish_change();
122+
assert!(
123+
editor.changed(),
124+
"Editor should be set as modified after insert() and finish_change()"
125+
);
126+
editor.save_point();
127+
assert!(
128+
!editor.changed(),
129+
"Editor should be unchanged after setting a save point"
130+
);
131+
132+
editor.undo();
133+
assert!(
134+
editor.changed(),
135+
"Undoing past save point should set editor as changed"
136+
);
137+
editor.redo();
138+
assert!(
139+
!editor.changed(),
140+
"Redoing to save point should set editor as unchanged"
141+
);
142+
}
143+
144+
#[test]
145+
fn redoing_past_save_point_sets_editor_to_changed() {
146+
let mut editor = editor();
147+
148+
// Save point change to undo to and then redo past.
149+
editor.start_change();
150+
editor.insert_string("Walt Whitman ", None);
151+
editor.finish_change();
152+
153+
// Set save point to the change above.
154+
assert!(
155+
editor.changed(),
156+
"Editor should be set as modified after insert() and finish_change()"
157+
);
158+
editor.save_point();
159+
assert!(
160+
!editor.changed(),
161+
"Editor should be unchanged after setting a save point"
162+
);
163+
164+
editor.start_change();
165+
editor.insert_string("Allen Ginsberg ", None);
166+
editor.finish_change();
167+
168+
editor.start_change();
169+
editor.insert_string("Jack Kerouac ", None);
170+
editor.finish_change();
171+
172+
assert!(editor.changed(), "Editor should be modified insertion");
173+
174+
// Undo to Whitman
175+
editor.undo();
176+
editor.undo();
177+
assert!(
178+
!editor.changed(),
179+
"Editor should be unmodified after undoing to the save point"
180+
);
181+
182+
// Redo to Kerouac
183+
editor.redo();
184+
editor.redo();
185+
assert!(
186+
editor.changed(),
187+
"Editor should be modified after redoing past the save point"
188+
);
189+
}
190+
191+
#[test]
192+
fn undoing_past_save_point_sets_editor_to_changed() {
193+
let mut editor = editor();
194+
195+
editor.start_change();
196+
editor.insert_string("Robert Fripp ", None);
197+
editor.finish_change();
198+
199+
// Save point change to undo past.
200+
editor.start_change();
201+
editor.insert_string("Thurston Moore ", None);
202+
editor.finish_change();
203+
204+
assert!(editor.changed(), "Editor should be changed after insertion");
205+
editor.save_point();
206+
assert!(
207+
!editor.changed(),
208+
"Editor should be unchanged after setting a save point"
209+
);
210+
211+
editor.start_change();
212+
editor.insert_string("Kim Deal ", None);
213+
editor.finish_change();
214+
215+
// Undo to the first change
216+
editor.undo();
217+
editor.undo();
218+
assert!(
219+
editor.changed(),
220+
"Editor should be changed after undoing past save point"
221+
);
222+
}
223+
224+
// #[test]
225+
// fn undo_all_changes() {
226+
// unimplemented!()
227+
// }

0 commit comments

Comments
 (0)