1use core::time::Duration;
24
25use blockworx_doc::{rev::Rev, trail::Trail};
26use blockworx_geom::Pos2;
27use blockworx_paint::Vantage;
28use blockworx_store::storage::Name;
29
30use undoer::Undoer;
31
32use crate::{
33 SelectTool,
34 multi_pin_select::MultiPinSelect,
35 multi_select::MultiSelect,
36 path::BlockPath,
37 resize_block::ResizeBlock,
38 tool::{Deletable, Tool, select_tool_for_anchor},
39 widget::drawing::Drawing,
40};
41
42mod undoer;
43
44pub const COALESCE: Duration = Duration::from_millis(1500);
48
49const DEPTH: usize = 100;
52
53#[derive(Clone, Debug, PartialEq)]
59pub struct Selection {
60 pub what: Deletable,
61 pub anchor: Option<Pos2>,
62}
63
64#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default)]
75pub struct Stood(Rev);
76
77impl Stood {
78 pub fn of(trail: &Trail) -> Self {
79 Self(trail.standing())
80 }
81}
82
83#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
88pub enum Moved {
89 #[default]
90 Camera,
91 Fit,
92 Focus,
93 Scope,
94 Edit,
96 Rename,
99}
100
101impl Moved {
102 pub fn label(self) -> &'static str {
105 match self {
106 Moved::Camera => "the camera move",
107 Moved::Fit => "zoom to fit",
108 Moved::Focus => "the focus",
109 Moved::Scope => "the change of scope",
110 Moved::Edit => "the edit",
111 Moved::Rename => "the rename",
112 }
113 }
114}
115
116#[derive(Clone, Copy, PartialEq, Eq, Debug)]
118pub struct Rename<'a> {
119 pub from: &'a Name,
120 pub to: &'a Name,
121}
122
123impl Rename<'_> {
124 pub fn label(self) -> String {
126 let spelled = |name: &Name| blockworx_editor::import::file_stem(name.as_str());
127 format!(
128 "Rename document \u{201c}{}\u{201d} to \u{201c}{}\u{201d}",
129 spelled(self.from),
130 spelled(self.to),
131 )
132 }
133}
134
135#[derive(Clone, PartialEq, Eq, Debug)]
142pub enum Naming {
143 Settled(Option<Name>),
144 Asked(Name),
145}
146
147impl Naming {
148 pub fn stands_under(&self) -> Option<&Name> {
151 match self {
152 Naming::Settled(name) => name.as_ref(),
153 Naming::Asked(name) => Some(name),
154 }
155 }
156}
157
158#[derive(Clone, Debug)]
172pub struct State {
173 pub camera: Vantage,
174 pub scope: BlockPath,
175 pub stood: Stood,
176 pub named: Option<Name>,
177 pub moved: Moved,
178 pub selection: Option<Selection>,
179}
180
181impl PartialEq for State {
182 fn eq(&self, other: &Self) -> bool {
183 self.camera == other.camera
184 && self.scope == other.scope
185 && self.stood == other.stood
186 && self.named == other.named
187 }
188}
189
190impl State {
191 pub fn rename_to<'a>(&'a self, to: &'a State) -> Option<Rename<'a>> {
194 match (&self.named, &to.named) {
195 (Some(from), Some(to)) if from != to => Some(Rename { from, to }),
196 _ => None,
197 }
198 }
199}
200
201#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
204pub enum Kind {
205 Doc,
207 Rename,
209 View,
211}
212
213impl Kind {
214 pub fn of_step(now: &State, target: &State) -> Self {
217 if target.stood != now.stood {
218 Kind::Doc
219 } else if now.rename_to(target).is_some() {
220 Kind::Rename
221 } else {
222 Kind::View
223 }
224 }
225
226 pub fn writes(self) -> bool {
229 match self {
230 Kind::Doc | Kind::Rename => true,
231 Kind::View => false,
232 }
233 }
234}
235
236#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
239pub struct Consequence {
240 pub target: String,
241 pub kind: Kind,
242}
243
244#[derive(Clone, Copy, PartialEq, Eq, Debug)]
247pub enum Direction {
248 Back,
249 Forward,
250}
251
252impl Direction {
253 pub fn verb(self) -> &'static str {
255 match self {
256 Direction::Back => "Undo",
257 Direction::Forward => "Redo",
258 }
259 }
260}
261
262#[derive(Clone, Copy, PartialEq, Eq, Debug)]
264pub enum Offered {
265 Yes,
266 No,
267}
268
269impl From<bool> for Offered {
270 fn from(offered: bool) -> Self {
271 if offered { Offered::Yes } else { Offered::No }
272 }
273}
274
275pub fn consequence(
282 step: Direction,
283 of: Option<&Consequence>,
284 offered: Offered,
285 viewing: blockworx_store::doc::Viewing,
286) -> String {
287 let verb = step.verb();
288 if offered == Offered::No && matches!(viewing, blockworx_store::doc::Viewing::Past(_)) {
289 return format!("{verb} \u{2014} return to current first");
290 }
291 let Some(of) = of else {
292 return format!("{verb} \u{2014} nothing to take back");
293 };
294 let costs = match of.kind {
295 Kind::Doc => "authors a rev",
296 Kind::Rename => "renames the document, no rev",
297 Kind::View => "view only, no rev",
298 };
299 match of.target.as_str() {
300 "" => format!("{verb} \u{2014} {costs}"),
301 target => format!("{verb} {target} \u{2014} {costs}"),
302 }
303}
304
305#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
310pub enum Recording {
311 #[default]
312 On,
313 Suspended,
314}
315
316pub struct UndoStack {
317 undoer: Undoer<State>,
318}
319
320impl UndoStack {
321 pub fn opening(at: &State) -> Self {
323 let mut stack = Self {
324 undoer: Undoer::with_settings(undoer::Settings {
325 max_undos: DEPTH,
326 stable_time: COALESCE,
327 ..Default::default()
328 }),
329 };
330 stack.undoer.add_undo(at);
331 stack
332 }
333
334 pub fn reconstructed(trail: &Trail, at: &State) -> Self {
344 let line = trail.standings();
345 let point = |depth: usize| State {
346 stood: Stood(line[depth]),
347 ..at.clone()
348 };
349 let mut stack = Self::opening(&point(0));
350 let past = trail.undo_depth();
351 let future = trail.redo_depth();
352 for depth in 1..=(past + future) {
353 stack.undoer.add_undo(&point(depth));
354 }
355 for depth in ((past + 1)..=(past + future)).rev() {
359 stack.undoer.undo(&point(depth));
360 }
361 stack
362 }
363
364 pub fn feed(&mut self, at: Duration, now: &State) {
367 self.undoer.feed_state(at, now);
368 }
369
370 pub fn edited(&mut self, at: Duration, before: &State, now: &State) {
378 self.undoer.feed_state(at, now);
379 self.undoer.add_undo(before);
380 self.undoer.add_undo(now);
381 }
382
383 pub fn landed(&mut self, at: &State) {
392 self.undoer.add_undo(at);
393 }
394
395 pub fn peek(&self, direction: Direction, now: &State) -> Option<State> {
402 let mut asked = UndoStack {
403 undoer: self.undoer.clone(),
404 };
405 asked.step(direction, now)
406 }
407
408 pub fn step(&mut self, direction: Direction, now: &State) -> Option<State> {
410 match direction {
411 Direction::Back => self.undoer.undo(now).cloned(),
412 Direction::Forward => self.undoer.redo(now).cloned(),
413 }
414 }
415}
416
417pub fn tool_for(data: &Drawing<'_>, candidates: &[&Selection]) -> Tool {
426 candidates
427 .iter()
428 .find_map(|selection| resolve(data, selection))
429 .unwrap_or_else(|| SelectTool.into())
430}
431
432fn resolve(data: &Drawing<'_>, selection: &Selection) -> Option<Tool> {
434 match &selection.what {
435 Deletable::Shape(shape) => data
436 .shape(*shape)
437 .is_some()
438 .then(|| ResizeBlock::Selected { shape: *shape }.into()),
439 Deletable::Route(id) => {
440 let anchor = selection.anchor?;
441 data.auto_route(*id)
442 .is_some()
443 .then(|| crate::EditRoute::Selected { id: *id, anchor }.into())
444 }
445 Deletable::Shapes(shapes) => {
446 let live: Vec<_> = shapes
447 .iter()
448 .copied()
449 .filter(|id| data.shape(*id).is_some())
450 .collect();
451 match live.len() {
452 0 => None,
453 1 => Some(ResizeBlock::Selected { shape: live[0] }.into()),
454 _ => Some(MultiSelect::Selected { shapes: live }.into()),
455 }
456 }
457 Deletable::Pins(pins) => {
458 let live: Vec<_> = pins
459 .iter()
460 .copied()
461 .filter(|id| data.pin_on_shape(*id).is_some())
462 .collect();
463 match live.len() {
464 0 => None,
465 1 => Some(select_tool_for_anchor(data, live[0])),
466 _ => Some(MultiPinSelect::Selected { pins: live }.into()),
467 }
468 }
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use blockworx_doc::fixtures::{block_id, rev};
476 use blockworx_geom::{Vec2, pos2, vec2};
477
478 fn vantage(x: f32) -> Vantage {
479 Vantage {
480 zoom: blockworx_paint::Zoom::unity(),
481 translation: vec2(x, 0.0),
482 }
483 }
484
485 fn state() -> State {
486 State {
487 camera: Vantage {
488 zoom: blockworx_paint::Zoom::unity(),
489 translation: Vec2::ZERO,
490 },
491 scope: BlockPath::empty(),
492 stood: Stood::default(),
493 named: Name::of_document("rig"),
494 moved: Moved::default(),
495 selection: None,
496 }
497 }
498
499 fn called(name: &str) -> State {
500 State {
501 named: Name::of_document(name),
502 moved: Moved::Rename,
503 ..state()
504 }
505 }
506
507 fn looking_at(x: f32) -> State {
508 State {
509 camera: vantage(x),
510 ..state()
511 }
512 }
513
514 fn inside(n: u32) -> State {
515 let mut scope = BlockPath::empty();
516 scope.push(block_id(n));
517 State { scope, ..state() }
518 }
519
520 fn edited(depth: u64) -> State {
521 State {
522 stood: Stood(rev(depth)),
523 moved: Moved::Edit,
524 ..state()
525 }
526 }
527
528 fn shape(n: u32) -> Selection {
529 Selection {
530 what: Deletable::Shape(crate::shape::ShapeId::Rect(block_id(n))),
531 anchor: None,
532 }
533 }
534
535 fn later(at: Duration) -> Duration {
537 at + COALESCE + Duration::from_millis(1)
538 }
539
540 fn settle(stack: &mut UndoStack, at: Duration, now: &State) -> Duration {
543 stack.feed(at, now);
544 let at = later(at);
545 stack.feed(at, now);
546 at
547 }
548
549 #[test]
553 fn two_camera_moves_inside_the_window_are_one_entry_and_a_third_outside_is_a_second() {
554 let start = state();
555 let mut stack = UndoStack::opening(&start);
556 let mut at = Duration::ZERO;
557
558 stack.feed(at, &looking_at(10.0));
561 at += COALESCE / 3;
562 stack.feed(at, &looking_at(20.0));
563 at = settle(&mut stack, at, &looking_at(20.0));
564
565 stack.feed(at, &looking_at(30.0));
567 let at = settle(&mut stack, at, &looking_at(30.0));
568 assert!(at > COALESCE, "precondition: the third move is a new entry");
569
570 let here = looking_at(30.0);
571 let first = stack.step(Direction::Back, &here).expect("one step back");
572 assert_eq!(
573 first.camera,
574 vantage(20.0),
575 "the third move was buried in the pair before it",
576 );
577 let second = stack.step(Direction::Back, &first).expect("two steps back");
578 assert_eq!(
579 second.camera, start.camera,
580 "the two moves inside the window cost two presses instead of one",
581 );
582 assert!(
583 stack.peek(Direction::Back, &second).is_none(),
584 "the stack held more entries than the moves that were made",
585 );
586 }
587
588 #[test]
592 fn a_camera_worked_without_pause_leaves_one_entry() {
593 let start = state();
594 let mut stack = UndoStack::opening(&start);
595 let mut at = Duration::ZERO;
596 for step in 1..=40 {
597 at += COALESCE / 4;
598 stack.feed(at, &looking_at(step as f32));
599 }
600 let here = looking_at(40.0);
601 settle(&mut stack, at, &here);
602
603 let back = stack.step(Direction::Back, &here).expect("one step back");
604 assert_eq!(
605 back.camera, start.camera,
606 "a gesture's frames became entries of their own",
607 );
608 assert!(
609 stack.peek(Direction::Back, &back).is_none(),
610 "and only the one entry"
611 );
612 }
613
614 #[test]
617 fn doc_and_view_entries_come_back_in_the_order_they_were_made() {
618 let start = state();
619 let mut stack = UndoStack::opening(&start);
620 let at = Duration::ZERO;
621
622 let moved = looking_at(10.0);
623 let at = settle(&mut stack, at, &moved);
624 let one_edit = State {
625 camera: moved.camera,
626 ..edited(1)
627 };
628 stack.edited(at, &moved, &one_edit);
629 let two_edits = State {
630 camera: moved.camera,
631 ..edited(2)
632 };
633 stack.edited(at, &one_edit, &two_edits);
634 let wandered = State {
635 camera: vantage(20.0),
636 ..two_edits.clone()
637 };
638 let _ = settle(&mut stack, at, &wandered);
639
640 let mut here = wandered;
641 let mut walked = Vec::new();
642 while let Some(back) = stack.step(Direction::Back, &here) {
643 walked.push((back.stood, back.camera));
644 here = back;
645 }
646 assert_eq!(
647 walked,
648 vec![
649 (Stood(rev(2)), vantage(10.0)),
650 (Stood(rev(1)), vantage(10.0)),
651 (Stood(rev(0)), vantage(10.0)),
652 (Stood(rev(0)), start.camera),
653 ],
654 "the walk skipped a kind or reordered the two",
655 );
656 }
657
658 #[test]
662 fn edits_inside_the_coalescing_window_do_not_merge() {
663 let start = state();
664 let mut stack = UndoStack::opening(&start);
665 let at = Duration::ZERO;
666 let one = edited(1);
667 let two = edited(2);
668 stack.edited(at, &start, &one);
669 stack.edited(at + COALESCE / 10, &one, &two);
670
671 let back = stack.step(Direction::Back, &two).expect("one step back");
672 assert_eq!(back.stood, Stood(rev(1)), "one press took back two edits");
673 }
674
675 #[test]
678 fn redo_returns_to_where_undo_found_us_and_a_new_move_abandons_it() {
679 let start = state();
680 let mut stack = UndoStack::opening(&start);
681 let moved = looking_at(10.0);
682 let at = settle(&mut stack, Duration::ZERO, &moved);
683
684 let back = stack.step(Direction::Back, &moved).expect("a step back");
685 assert_eq!(back.camera, start.camera);
686 assert!(
687 stack.peek(Direction::Forward, &back).is_some(),
688 "the move is not on the forward half"
689 );
690 let forward = stack.step(Direction::Forward, &back).expect("a step on");
691 assert_eq!(forward.camera, moved.camera);
692
693 let back = stack.step(Direction::Back, &forward).expect("a step back");
694 let elsewhere = looking_at(99.0);
695 stack.feed(later(at), &elsewhere);
696 assert!(
697 stack.peek(Direction::Forward, &back).is_none(),
698 "a fresh move outlived the future it forked away from",
699 );
700 }
701
702 #[test]
706 fn a_scope_change_is_a_view_entry() {
707 let start = state();
708 let mut stack = UndoStack::opening(&start);
709 let deeper = inside(7);
710 assert_ne!(deeper.scope, start.scope, "precondition: the scope moved");
711 settle(&mut stack, Duration::ZERO, &deeper);
712
713 let back = stack.step(Direction::Back, &deeper).expect("a step back");
714 assert_eq!(back.scope, start.scope);
715 assert_eq!(back.stood, start.stood, "a scope change touched the log");
716 }
717
718 #[test]
721 fn a_reconstructed_stack_walks_the_trail_it_came_from() {
722 use blockworx_doc::trail::JournalAs;
723
724 let mut trail = Trail::default();
725 for at in 1..=3 {
726 trail.record(rev(at), JournalAs::Edit);
727 }
728 trail.record(rev(4), JournalAs::Undo { of: rev(3) });
729 assert_eq!(
730 (trail.undo_depth(), trail.redo_depth()),
731 (2, 1),
732 "precondition: the trail has depth both ways to reproduce",
733 );
734
735 let here = State {
736 stood: Stood::of(&trail),
737 ..state()
738 };
739 let mut stack = UndoStack::reconstructed(&trail, &here);
740 assert!(
741 stack.peek(Direction::Back, &here).is_some(),
742 "the reopened depth was not offered"
743 );
744 assert!(
745 stack.peek(Direction::Forward, &here).is_some(),
746 "the forward half was dropped"
747 );
748
749 let forward = stack.step(Direction::Forward, &here).expect("a step on");
750 assert_eq!(
751 forward.stood,
752 Stood(rev(3)),
753 "redo did not reach the trail's own future",
754 );
755 let mut walking = forward;
756 let mut stops = Vec::new();
757 while let Some(back) = stack.step(Direction::Back, &walking) {
758 stops.push(back.stood);
759 walking = back;
760 }
761 assert_eq!(
762 stops,
763 vec![Stood(rev(2)), Stood(rev(1)), Stood(rev(0))],
764 "the walk back does not match the trail it was built from",
765 );
766 }
767
768 #[test]
771 fn a_selection_change_is_not_an_entry_of_its_own() {
772 let start = state();
773 let mut stack = UndoStack::opening(&start);
774 let picked = State {
775 selection: Some(shape(4)),
776 ..state()
777 };
778 assert_eq!(picked, start, "the selection is outside the comparison");
779 settle(&mut stack, Duration::ZERO, &picked);
780 assert!(
781 stack.peek(Direction::Back, &picked).is_none(),
782 "picking something became something to take back",
783 );
784 }
785
786 #[test]
788 fn a_restored_selection_resolves_to_the_tool_that_holds_it() {
789 use crate::widget::test_fixtures::two_blocks_with_a_routed_waypoint;
790 let mut scene = two_blocks_with_a_routed_waypoint();
791 let drawing = scene.drawing();
792
793 assert!(
794 matches!(tool_for(&drawing, &[&shape(1)]), Tool::ResizeBlock(_)),
795 "a selected block restores to the tool that shows its overlay",
796 );
797 assert!(
798 matches!(tool_for(&drawing, &[]), Tool::Select(_)),
799 "no candidate restores to plain select",
800 );
801 }
802
803 #[test]
806 fn a_candidate_the_document_lost_gives_way_to_the_next() {
807 use crate::widget::test_fixtures::two_blocks_with_a_routed_waypoint;
808 let mut scene = two_blocks_with_a_routed_waypoint();
809 let drawing = scene.drawing();
810 let gone = shape(200);
811 assert!(
812 drawing
813 .shape(gone.what.shapes().expect("a shape")[0])
814 .is_none(),
815 "precondition: the document does not hold the first candidate",
816 );
817
818 assert!(
819 matches!(
820 tool_for(&drawing, &[&gone, &shape(1)]),
821 Tool::ResizeBlock(_)
822 ),
823 "the surviving candidate is taken",
824 );
825 assert!(
826 matches!(tool_for(&drawing, &[&gone]), Tool::Select(_)),
827 "and with none surviving, nothing is selected",
828 );
829 }
830
831 #[test]
835 fn a_route_candidate_without_its_anchor_is_passed_over() {
836 use crate::widget::test_fixtures::two_blocks_with_a_routed_waypoint;
837 let mut scene = two_blocks_with_a_routed_waypoint();
838 let drawing = scene.drawing();
839 let route = drawing
840 .auto_routes()
841 .next()
842 .map(|(id, _)| id)
843 .expect("the fixture's route");
844
845 let anchored = Selection {
846 what: Deletable::Route(route),
847 anchor: Some(pos2(30.0, 30.0)),
848 };
849 assert!(
850 matches!(tool_for(&drawing, &[&anchored]), Tool::EditRoute(_)),
851 "with its anchor, a route restores to the route editor",
852 );
853 let anchorless = Selection {
854 anchor: None,
855 ..anchored
856 };
857 assert!(matches!(
858 tool_for(&drawing, &[&anchorless]),
859 Tool::Select(_)
860 ));
861 }
862
863 #[test]
866 fn a_rename_is_an_entry_of_its_own_and_steps_back_as_a_rename() {
867 let start = state();
868 let mut stack = UndoStack::opening(&start);
869 let renamed = called("engine");
870 assert_ne!(
871 renamed, start,
872 "precondition: the name is in the comparison"
873 );
874 stack.edited(Duration::ZERO, &start, &renamed);
875
876 let back = stack.step(Direction::Back, &renamed).expect("a step back");
877 assert_eq!(back.named, start.named, "the step back kept the new name");
878 assert_eq!(Kind::of_step(&renamed, &back), Kind::Rename);
879 let taken_back = Consequence {
880 target: back.rename_to(&renamed).expect("a rename").label(),
881 kind: Kind::Rename,
882 };
883 assert_eq!(
884 consequence(
885 Direction::Back,
886 Some(&taken_back),
887 Offered::Yes,
888 blockworx_store::doc::Viewing::Head,
889 ),
890 "Undo Rename document \u{201c}rig\u{201d} to \u{201c}engine\u{201d} \u{2014} renames the document, no rev",
891 );
892
893 let forward = stack
894 .peek(Direction::Forward, &back)
895 .expect("the rename is on the forward half");
896 assert_eq!(forward.named, renamed.named);
897 assert_eq!(Kind::of_step(&back, &forward), Kind::Rename);
898 }
899
900 #[test]
903 fn a_step_is_the_costliest_thing_it_crosses() {
904 let here = state();
905 assert_eq!(Kind::of_step(&here, &looking_at(5.0)), Kind::View);
906 assert_eq!(Kind::of_step(&here, &called("engine")), Kind::Rename);
907 let edited_and_renamed = State {
908 stood: Stood(rev(1)),
909 ..called("engine")
910 };
911 assert_eq!(Kind::of_step(&here, &edited_and_renamed), Kind::Doc);
912 let unnamed = State {
913 named: None,
914 ..state()
915 };
916 assert_ne!(unnamed, here, "precondition: the two states differ");
917 assert_eq!(
918 Kind::of_step(&here, &unnamed),
919 Kind::View,
920 "a step to no container renamed it to nothing",
921 );
922 assert!(Kind::Doc.writes());
923 assert!(Kind::Rename.writes());
924 assert!(!Kind::View.writes());
925 }
926}