diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs index 11bd4e64a7..cdca056f0a 100644 --- a/apps/desktop-gpui/src/screenshot_annotations.rs +++ b/apps/desktop-gpui/src/screenshot_annotations.rs @@ -1891,12 +1891,31 @@ impl ScreenshotEditorWindow { &mut self, window: &mut Window, cx: &mut Context, + ) -> bool { + self.finish_annotation_gesture(true, window, cx) + } + + pub(crate) fn finish_annotation_history_edit( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + self.end_annotation_slider(cx); + self.finish_annotation_gesture(false, window, cx); + self.finish_reorder(window, cx); + } + + fn finish_annotation_gesture( + &mut self, + edit_text: bool, + window: &mut Window, + cx: &mut Context, ) -> bool { let Some(gesture) = self.annotation_state.gesture.take() else { return false; }; match gesture { - Gesture::Create(creating) => self.finish_create(creating, window, cx), + Gesture::Create(creating) => self.finish_create(creating, edit_text, window, cx), Gesture::Move(dragging) | Gesture::Resize(dragging) => { self.finish_drag(dragging, window, cx) } @@ -1904,7 +1923,13 @@ impl ScreenshotEditorWindow { true } - fn finish_create(&mut self, creating: Creating, window: &mut Window, cx: &mut Context) { + fn finish_create( + &mut self, + creating: Creating, + edit_text: bool, + window: &mut Window, + cx: &mut Context, + ) { let mut annotation = creating.temp; if annotation.annotation_type == AnnotationType::Draw { @@ -1980,7 +2005,7 @@ impl ScreenshotEditorWindow { self.selected_annotation = Some(id.clone()); self.commit_annotations(window, cx); - if is_text { + if is_text && edit_text { self.begin_text_edit(id, window, cx); } } @@ -4830,4 +4855,313 @@ mod tests { after.x = 1.; assert!(geometry_changed(&before, &after)); } + + #[cfg(target_os = "linux")] + mod history_interruption { + use super::*; + use crate::editor_edits::ProjectHistory; + use gpui::{Render, point, size}; + + struct HistoryView { + editor: Entity, + _project: tempfile::TempDir, + } + + impl Render for HistoryView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child(self.editor.clone()) + } + } + + fn fixture( + cx: &mut gpui::TestAppContext, + annotations: Vec, + ) -> (Entity, &mut gpui::VisualTestContext) { + let project = tempfile::tempdir().unwrap(); + let (view, cx) = cx.add_window_view(move |window, cx| { + let editor = + cx.new(|cx| ScreenshotEditorWindow::new(project.path().into(), window, cx)); + editor.update(cx, |editor, cx| { + editor.project = cap_project::ProjectConfiguration { + annotations, + background: cap_project::BackgroundConfiguration { + padding: 0., + rounding: 0., + ..Default::default() + }, + ..Default::default() + }; + editor.history = ProjectHistory::new(editor.project.clone()); + editor.frame_size = (800., 600.); + editor.image_size = Some((800, 600)); + window.focus(&editor.focus, cx); + }); + HistoryView { + editor, + _project: project, + } + }); + (view.read_with(cx, |view, _| view.editor.clone()), cx) + } + + fn rectangle() -> Annotation { + shape(AnnotationType::Rectangle, 100., 100., 50., 30.) + } + + fn active_drag(editor: &mut ScreenshotEditorWindow, resize: bool, offset: f64) { + let mut drag = Dragging { + id: "a".into(), + handle: resize.then_some(Handle::E), + start: (100., 100.), + original: editor.project.annotations[0].clone(), + moved: false, + }; + if resize { + editor.drag_resize(&drag, (100. + offset, 100.), false); + editor.annotation_state.gesture = Some(Gesture::Resize(drag)); + } else { + editor.drag_move(&mut drag, (100. + offset, 100.)); + editor.annotation_state.gesture = Some(Gesture::Move(drag)); + } + } + + fn geometry(editor: &ScreenshotEditorWindow) -> (f64, f64) { + let annotation = &editor.project.annotations[0]; + (annotation.x, annotation.width) + } + + #[gpui::test] + fn active_move_and_resize_undo_before_prior_history(cx: &mut gpui::TestAppContext) { + for resize in [false, true] { + for prior in [false, true] { + let (editor, cx) = fixture(cx, vec![rectangle()]); + editor.update(cx, |editor, _| { + if prior { + editor.project.background.rounding = 12.; + editor.history.record(&editor.project); + } + active_drag(editor, resize, 30.); + }); + cx.simulate_keystrokes("ctrl-z"); + editor.read_with(cx, |editor, _| { + assert_eq!( + editor.project.background.rounding, + if prior { 12. } else { 0. } + ); + assert_eq!(geometry(editor), (100., 50.)); + assert!(editor.annotation_state.gesture.is_none()); + assert!(editor.history.can_redo()); + }); + editor.update_in(cx, |editor, window, cx| { + assert!(!editor.annotation_mouse_move( + point(px(200.), px(100.)), + window, + cx + )); + assert!(!editor.annotation_mouse_up(window, cx)); + }); + cx.simulate_keystrokes("ctrl-shift-z"); + editor.read_with(cx, |editor, _| { + assert_eq!( + geometry(editor), + if resize { (100., 80.) } else { (130., 50.) } + ); + }); + } + } + } + + #[gpui::test] + fn redo_distinguishes_changed_and_noop_annotation_drags(cx: &mut gpui::TestAppContext) { + for resize in [false, true] { + for changed in [false, true] { + let (editor, cx) = fixture(cx, vec![rectangle()]); + editor.update(cx, |editor, _| { + editor.project.background.rounding = 12.; + editor.history.record(&editor.project); + }); + cx.simulate_keystrokes("cmd-z"); + editor.update(cx, |editor, _| { + active_drag(editor, resize, if changed { 30. } else { 0. }); + }); + cx.simulate_keystrokes("cmd-y"); + editor.read_with(cx, |editor, _| { + assert_eq!( + editor.project.background.rounding, + if changed { 0. } else { 12. } + ); + assert!(editor.annotation_state.gesture.is_none()); + assert!(!editor.history.can_redo()); + assert_eq!( + geometry(editor), + if changed && resize { + (100., 80.) + } else if changed { + (130., 50.) + } else { + (100., 50.) + } + ); + }); + editor.update_in(cx, |editor, window, cx| { + assert!(!editor.annotation_mouse_up(window, cx)); + }); + } + } + } + + #[gpui::test] + fn creation_undo_consumes_preview_and_does_not_focus_undone_text( + cx: &mut gpui::TestAppContext, + ) { + for tool in [ + Tool::Arrow, + Tool::Rectangle, + Tool::Circle, + Tool::Draw, + Tool::Mask, + Tool::Text, + ] { + let (editor, cx) = fixture(cx, Vec::new()); + editor.update(cx, |editor, cx| { + editor.begin_create(tool, (100., 100.), cx); + let Some(Gesture::Create(creating)) = editor.annotation_state.gesture.take() + else { + panic!("expected creation gesture"); + }; + editor.annotation_state.gesture = Some(Gesture::Create(editor.drag_create( + creating, + (150., 130.), + false, + ))); + }); + cx.simulate_keystrokes("ctrl-z"); + cx.run_until_parked(); + editor.update_in(cx, |editor, window, cx| { + assert!(editor.project.annotations.is_empty()); + assert!(editor.annotation_state.gesture.is_none()); + assert!(editor.annotation_state.editing.is_none()); + assert!(editor.focus.is_focused(window)); + assert!(editor.history.can_redo()); + assert!(!editor.annotation_mouse_up(window, cx)); + }); + cx.simulate_keystrokes("ctrl-shift-z"); + editor.read_with(cx, |editor, _| { + assert_eq!(editor.project.annotations.len(), 1); + assert!(editor.annotation_state.editing.is_none()); + }); + } + } + + #[gpui::test] + fn tiny_creation_does_not_discard_redo_or_commit_on_late_release( + cx: &mut gpui::TestAppContext, + ) { + for tool in [Tool::Rectangle, Tool::Circle, Tool::Draw, Tool::Mask] { + let (editor, cx) = fixture(cx, Vec::new()); + editor.update(cx, |editor, _| { + editor.project.background.rounding = 12.; + editor.history.record(&editor.project); + }); + cx.simulate_keystrokes("ctrl-z"); + editor.update(cx, |editor, cx| editor.begin_create(tool, (100., 100.), cx)); + cx.simulate_keystrokes("ctrl-y"); + editor.update_in(cx, |editor, window, cx| { + assert_eq!(editor.project.background.rounding, 12.); + assert!(editor.project.annotations.is_empty()); + assert!(editor.annotation_state.gesture.is_none()); + assert!(!editor.annotation_mouse_up(window, cx)); + }); + } + } + + #[gpui::test] + fn reorder_undo_and_noop_redo_consume_the_pending_drop(cx: &mut gpui::TestAppContext) { + let mut second = rectangle(); + second.id = "b".into(); + let (editor, cx) = fixture(cx, vec![rectangle(), second]); + editor.update(cx, |editor, cx| { + editor.project.background.rounding = 12.; + editor.history.record(&editor.project); + editor.begin_reorder("a".into(), cx); + editor.annotation_state.reorder.as_mut().unwrap().target = Some(0); + }); + cx.simulate_keystrokes("ctrl-z"); + editor.update_in(cx, |editor, window, cx| { + assert_eq!(editor.project.background.rounding, 12.); + assert_eq!(editor.project.annotations[0].id, "a"); + assert!(editor.annotation_state.reorder.is_none()); + editor.finish_reorder(window, cx); + editor.begin_reorder("a".into(), cx); + editor.annotation_state.reorder.as_mut().unwrap().target = Some(2); + }); + cx.simulate_keystrokes("ctrl-y"); + editor.read_with(cx, |editor, _| { + assert_eq!(editor.project.annotations[0].id, "b"); + assert!(editor.annotation_state.reorder.is_none()); + }); + } + + #[gpui::test] + fn annotation_slider_history_releases_pause_and_preserves_redo_rules( + cx: &mut gpui::TestAppContext, + ) { + for changed in [false, true] { + let (editor, cx) = fixture(cx, vec![rectangle()]); + editor.update_in(cx, |editor, window, cx| { + editor.selected_annotation = Some("a".into()); + editor.annotation_state.slider_tracks[&AnnotationSlider::Width].set(Some( + Bounds { + origin: point(px(0.), px(0.)), + size: size(px(100.), px(4.)), + }, + )); + editor.begin_annotation_slider( + AnnotationSlider::Width, + point(px(0.), px(0.)), + window, + cx, + ); + }); + cx.simulate_keystrokes("ctrl-z"); + editor.update_in(cx, |editor, window, cx| { + assert_eq!(editor.project.annotations[0].stroke_width, 4.); + assert!(editor.annotation_state.active_slider.is_none()); + editor.end_annotation_slider(cx); + let x = if changed { 100. } else { 300. / 19. }; + editor.begin_annotation_slider( + AnnotationSlider::Width, + point(px(x), px(0.)), + window, + cx, + ); + }); + cx.simulate_keystrokes("ctrl-shift-z"); + editor.update_in(cx, |editor, window, cx| { + assert_eq!( + editor.project.annotations[0].stroke_width, + if changed { 20. } else { 1. } + ); + assert!(editor.annotation_state.active_slider.is_none()); + editor.end_annotation_slider(cx); + editor.edit_project( + |project| { + project.background.rounding = 12.; + true + }, + window, + cx, + ); + }); + cx.simulate_keystrokes("ctrl-z"); + editor.read_with(cx, |editor, _| { + assert_eq!(editor.project.background.rounding, 0.); + assert_eq!( + editor.project.annotations[0].stroke_width, + if changed { 20. } else { 1. } + ); + }); + } + } + } } diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index af94097053..cf428bba96 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -1237,6 +1237,8 @@ impl ScreenshotEditorWindow { // -- History --------------------------------------------------------------- fn undo(&mut self, window: &mut Window, cx: &mut Context) { + self.end_slider(cx); + self.finish_annotation_history_edit(window, cx); let Some(config) = self.history.undo().cloned() else { return; }; @@ -1244,6 +1246,8 @@ impl ScreenshotEditorWindow { } fn redo(&mut self, window: &mut Window, cx: &mut Context) { + self.end_slider(cx); + self.finish_annotation_history_edit(window, cx); let Some(config) = self.history.redo().cloned() else { return; }; @@ -5464,4 +5468,200 @@ mod tests { } } } + + #[cfg(target_os = "linux")] + mod history_interruption { + use super::*; + use gpui::point; + + struct HistoryView { + editor: Entity, + _project: tempfile::TempDir, + } + + impl Render for HistoryView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + self.editor.update(cx, |editor, cx| { + div() + .size_full() + .track_focus(&editor.focus) + .on_key_down(cx.listener(ScreenshotEditorWindow::on_key)) + }) + } + } + + fn fixture( + cx: &mut gpui::TestAppContext, + ) -> ( + Entity, + tokio::sync::watch::Receiver, + &mut gpui::VisualTestContext, + ) { + let project = tempfile::tempdir().unwrap(); + let config = ProjectConfiguration { + background: cap_project::BackgroundConfiguration { + source: BackgroundSource::Color { + value: [255, 255, 255], + alpha: 255, + }, + rounding: 0., + blur: 0., + ..Default::default() + }, + ..Default::default() + }; + let (config_tx, config_rx) = tokio::sync::watch::channel(ConfigUpdate { + revision: 0, + config: config.clone(), + }); + let (view, cx) = cx.add_window_view(move |window, cx| { + let editor = + cx.new(|cx| ScreenshotEditorWindow::new(project.path().into(), window, cx)); + editor.update(cx, |editor, cx| { + editor.history = ProjectHistory::new(config.clone()); + editor.project = config; + editor.render_gate = RenderInputGate::published(&mut editor.project); + editor.config_tx = Some(config_tx); + editor.track(StyleSlider::Rounding).set(Some(Bounds { + origin: point(px(0.), px(0.)), + size: gpui::size(px(100.), px(4.)), + })); + window.focus(&editor.focus, cx); + }); + HistoryView { + editor, + _project: project, + } + }); + ( + view.read_with(cx, |view, _| view.editor.clone()), + config_rx, + cx, + ) + } + + fn assert_synchronized( + editor: &ScreenshotEditorWindow, + updates: &tokio::sync::watch::Receiver, + ) { + assert!(editor.active_slider.is_none()); + let expected = serde_json::to_value(&editor.project).unwrap(); + let pending = editor.pending_save.borrow(); + assert_eq!( + serde_json::to_value(pending.config.as_ref().unwrap()).unwrap(), + expected + ); + let published = updates.borrow(); + assert_eq!(published.revision, editor.revision); + assert!(published.revision > 0); + assert_eq!(serde_json::to_value(&published.config).unwrap(), expected); + } + + #[gpui::test] + fn first_active_style_edit_undo_redo_updates_save_and_renderer( + cx: &mut gpui::TestAppContext, + ) { + for (undo, redo) in [("ctrl-z", "ctrl-shift-z"), ("cmd-z", "cmd-y")] { + let (editor, updates, cx) = fixture(cx); + editor.update_in(cx, |editor, window, cx| { + editor.begin_slider(StyleSlider::Rounding, point(px(30.), px(0.)), window, cx); + editor.apply_slider(StyleSlider::Rounding, point(px(50.), px(0.)), window, cx); + assert!(!editor.history.can_undo()); + }); + cx.simulate_keystrokes(undo); + editor.read_with(cx, |editor, _| { + assert_eq!(editor.project.background.rounding, 0.); + assert!(editor.history.can_redo()); + assert_synchronized(editor, &updates); + }); + editor.update(cx, |editor, cx| editor.end_slider(cx)); + cx.simulate_keystrokes(redo); + editor.read_with(cx, |editor, _| { + assert_eq!(editor.project.background.rounding, 50.); + assert!(!editor.history.can_redo()); + assert_synchronized(editor, &updates); + }); + } + } + + #[gpui::test] + fn active_style_undo_preserves_prior_edit_and_noop_redo(cx: &mut gpui::TestAppContext) { + for return_to_origin in [false, true] { + let (editor, updates, cx) = fixture(cx); + editor.update_in(cx, |editor, window, cx| { + editor.edit_project( + |project| StyleSlider::Rounding.apply(project, 20.), + window, + cx, + ); + editor.begin_slider(StyleSlider::Rounding, point(px(60.), px(0.)), window, cx); + }); + cx.simulate_keystrokes("ctrl-z"); + editor.read_with(cx, |editor, _| { + assert_eq!(editor.project.background.rounding, 20.); + assert!(editor.history.can_redo()); + assert_synchronized(editor, &updates); + }); + editor.update_in(cx, |editor, window, cx| { + let start = if return_to_origin { 40. } else { 20. }; + editor.begin_slider( + StyleSlider::Rounding, + point(px(start), px(0.)), + window, + cx, + ); + editor.apply_slider(StyleSlider::Rounding, point(px(20.), px(0.)), window, cx); + }); + cx.simulate_keystrokes("ctrl-y"); + editor.read_with(cx, |editor, _| { + assert_eq!(editor.project.background.rounding, 60.); + assert_synchronized(editor, &updates); + }); + editor.update_in(cx, |editor, window, cx| { + editor.end_slider(cx); + editor.end_slider(cx); + editor.edit_project( + |project| StyleSlider::Blur.apply(project, 12.), + window, + cx, + ); + }); + cx.simulate_keystrokes("ctrl-z"); + editor.read_with(cx, |editor, _| { + assert_eq!(editor.project.background.rounding, 60.); + assert_eq!(editor.project.background.blur, 0.); + assert_synchronized(editor, &updates); + }); + } + } + + #[gpui::test] + fn changed_active_style_edit_replaces_the_redo_branch(cx: &mut gpui::TestAppContext) { + let (editor, updates, cx) = fixture(cx); + editor.update_in(cx, |editor, window, cx| { + editor.edit_project( + |project| StyleSlider::Rounding.apply(project, 20.), + window, + cx, + ); + }); + cx.simulate_keystrokes("ctrl-z"); + editor.update_in(cx, |editor, window, cx| { + editor.begin_slider(StyleSlider::Rounding, point(px(40.), px(0.)), window, cx); + }); + cx.simulate_keystrokes("ctrl-shift-z"); + editor.read_with(cx, |editor, _| { + assert_eq!(editor.project.background.rounding, 40.); + assert!(!editor.history.can_redo()); + assert_synchronized(editor, &updates); + }); + editor.update(cx, |editor, cx| editor.end_slider(cx)); + cx.simulate_keystrokes("ctrl-z"); + editor.read_with(cx, |editor, _| { + assert_eq!(editor.project.background.rounding, 0.); + assert!(editor.history.can_redo()); + assert_synchronized(editor, &updates); + }); + } + } }