Skip to main content

blockworx_tools/
history.rs

1//! The editor's undo stack: two kinds of entry on one stack.
2//!
3//! A `doc` entry authors an inverse rev at the head; a `view` entry restores
4//! where the camera stood and which scope was open, and touches no log. Both
5//! are the same thing here — a [`State`] the editor once stood in — and one
6//! press walks to the previous one, authoring only as much of the document as
7//! the walk crosses.
8//!
9//! **The stack is an `Undoer`.** It is fed the current state every frame and
10//! decides for itself when a change has settled into an entry, which is
11//! the coalescing rule with [`COALESCE`] as its `stable_time`: a camera
12//! worked continuously never settles, so a pinch leaves one entry rather
13//! than one per frame, and two moves inside the window are one press
14//! to take back.
15//!
16//! The document half is *not* moved here. `blockworx_doc::trail`'s [`Trail`]
17//! remains what holds the steps — it is durable, and the reopened undo depth
18//! comes from it — and a state names which of its positions the document
19//! stands on through [`Stood`]. This stack is the session-level interleaving
20//! over that trail, and its own emptiness on reload is why viewing state does
21//! not survive a reload.
22
23use 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
44/// Consecutive same-type view entries inside this window are one entry. It
45/// is the `Undoer`'s `stable_time`, so it is also what makes a gesture end —
46/// rather than a gesture frame — the point an entry lands.
47pub const COALESCE: Duration = Duration::from_millis(1500);
48
49/// How many states the stack remembers. Past it the history panel is the
50/// surface, as it is for the log.
51const DEPTH: usize = 100;
52
53/// A selection, with the point a route was picked at.
54///
55/// Bundled because the anchor only means anything beside the thing it
56/// anchors: a route spans a wide box but is picked at one spot, and an
57/// anchor without a selection names nothing.
58#[derive(Clone, Debug, PartialEq)]
59pub struct Selection {
60    pub what: Deletable,
61    pub anchor: Option<Pos2>,
62}
63
64/// Which of the trail's positions the document stands on: the rev whose
65/// document the head currently holds.
66///
67/// **Not the head rev.** A step authors a commit of its own, so the head
68/// moves *forward* when the document steps back; feeding it would make
69/// every undo look like a fresh state and the stack would grow instead of
70/// walking. What the trail tracks instead is where a step *left* the
71/// session, which is stable across a round trip — an undo and its redo
72/// return to the same `Stood` — and ordered along both halves of the
73/// trail, which is what makes a walk toward one terminate.
74#[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/// What made a state, in the words the tooltip names it by.
84///
85/// Panning and zooming are one kind: a hand navigating with both would
86/// otherwise leave entries that alternate and so never coalesce.
87#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
88pub enum Moved {
89    #[default]
90    Camera,
91    Fit,
92    Focus,
93    Scope,
94    /// A document edit. Its words come from the commit, not from here.
95    Edit,
96    /// The container renamed. Its words come from the two names, not from
97    /// here.
98    Rename,
99}
100
101impl Moved {
102    /// What one press would take back, for a `view` entry. A `doc` entry is
103    /// named by its commit's own label instead.
104    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/// What the container is called on either side of a step that renames it.
117#[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    /// The step in the words a commit label would use for it.
125    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/// The container name the stack has accounted for.
136///
137/// A rename is performed by the front end, between frames, so the session
138/// learns of one by reading the document's name at the start of the next.
139/// A step that renames asks for it instead, and whatever name the door
140/// leaves the container under is that step's outcome, not a fresh rename.
141#[derive(Clone, PartialEq, Eq, Debug)]
142pub enum Naming {
143    Settled(Option<Name>),
144    Asked(Name),
145}
146
147impl Naming {
148    /// The name a state recorded now stands under — the one asked for, while
149    /// a step's door is still to be performed.
150    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/// One point on the stack: everywhere the editor was, as far as undo cares.
159///
160/// Equality is what the `Undoer` asks every frame to decide whether
161/// anything happened, so it covers exactly the camera, the scope, the
162/// document and the container's name — the kinds of entry and nothing else. Two fields ride
163/// along outside it:
164///
165/// - the **selection**, because picking something is how you *reach* an edit;
166///   a step per selection would cost several presses to get back past one
167///   edit and Cmd+Z would stop meaning "take back what I did". It still comes
168///   back with the state that holds it.
169/// - the **label**, because a fit that lands the camera where it already was
170///   is not a step.
171#[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    /// The rename a step from here to `to` makes, if it makes one. A state
192    /// with no container has no name to rename from or to.
193    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/// The kinds of entry, told apart by what taking the step crosses: a
202/// [`Stood`] boundary, a container name, or neither.
203#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
204pub enum Kind {
205    /// Authors an inverse rev at the head.
206    Doc,
207    /// Renames the container back. Authors no rev, but writes storage.
208    Rename,
209    /// Restores a camera and a scope. Touches no log.
210    View,
211}
212
213impl Kind {
214    /// What a step from `now` to `target` is, by the costliest thing it
215    /// crosses.
216    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    /// Whether taking the step writes anything — what a read-only session
227    /// and the lens withhold it on.
228    pub fn writes(self) -> bool {
229        match self {
230            Kind::Doc | Kind::Rename => true,
231            Kind::View => false,
232        }
233    }
234}
235
236/// What one press of undo — or of redo — would do: the thing it names, and
237/// what it costs the log. Invariant 8's whole content.
238#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
239pub struct Consequence {
240    pub target: String,
241    pub kind: Kind,
242}
243
244/// Which way a history step goes. Two variants, so "an edit is a history
245/// step" cannot be spelled.
246#[derive(Clone, Copy, PartialEq, Eq, Debug)]
247pub enum Direction {
248    Back,
249    Forward,
250}
251
252impl Direction {
253    /// The word the button carries.
254    pub fn verb(self) -> &'static str {
255        match self {
256            Direction::Back => "Undo",
257            Direction::Forward => "Redo",
258        }
259    }
260}
261
262/// Whether the session is offering a step, or drawing it dead.
263#[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
275/// What a press of undo — or of redo — will cost the log, in the sentence
276/// invariant 8 asks every such control to carry.
277///
278/// One spelling, because two shells print it, and the wording is the entry's
279/// rather than the button's. Under the lens the registry withholds the
280/// document half, and this says why rather than merely greying out.
281pub 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/// Whether a frame's own recording should happen. An undo or redo restores a
306/// state by definition, so the frame it runs in must not read the inverse
307/// commits it authored as fresh edits — which would push the step it just
308/// took straight back onto the stack.
309#[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    /// A stack holding nothing but where the session opens.
322    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    /// The stack a document that was replayed rather than edited opens on:
335    /// one point per entry of `trail`, both ways, in the trail's own order
336    /// — so the reopened undo depth is what the user sees from the first
337    /// frame.
338    ///
339    /// A reconstructed point has no camera or scope of its own: those are
340    /// session state and genuinely do not survive a restart, so every point
341    /// holds the one the document opens at. That is also why the walk they
342    /// drive is a document walk and lands the camera where it already is.
343    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        // Nothing can push a redo directly, so the future is walked back out
356        // of the past: each step moves one point across, and the last of them
357        // lands on the state the document actually stands in.
358        for depth in ((past + 1)..=(past + future)).rev() {
359            stack.undoer.undo(&point(depth));
360        }
361        stack
362    }
363
364    /// Offer the stack this frame's state. Whether it becomes an entry is the
365    /// `Undoer`'s call, and [`COALESCE`] is the rule it makes it by.
366    pub fn feed(&mut self, at: Duration, now: &State) {
367        self.undoer.feed_state(at, now);
368    }
369
370    /// Punctuate the stack with an edit: the state either side of it becomes
371    /// an entry immediately, so two edits inside the coalescing window are
372    /// still two presses to take back. View entries coalesce; document edits
373    /// are what the coalescing exists to stop burying.
374    ///
375    /// The feed goes first because it is what drops the forward history the
376    /// edit has just invalidated — the trail has already dropped its own half.
377    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    /// Pin where a step actually came to rest.
384    ///
385    /// A step that framed what it changed does not land on the point it
386    /// popped — the camera moved to bring the change into sight — and the
387    /// stack has to hold *that*, or the next frame's [`Self::feed`] would
388    /// read the framing as a fresh move and abandon the future the step just
389    /// created. Framing nothing lands on the popped point exactly, and this
390    /// is then the no-op it should be.
391    pub fn landed(&mut self, at: &State) {
392        self.undoer.add_undo(at);
393    }
394
395    /// The state one step would land on, without taking it.
396    ///
397    /// The `Undoer` keeps its points private and offers no peek, so the
398    /// question is asked of a copy. That is the honest way to ask it: a
399    /// second stack kept alongside to answer it would be the drift this
400    /// module exists to avoid.
401    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    /// Take one step, and say where it lands.
409    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
417/// The tool a restored selection resolves to — the one place that maps a
418/// selection back to the tool that holds it, so restoring and selecting
419/// cannot disagree about what "a block is selected" means.
420///
421/// `candidates` are tried in order and the first the document still holds
422/// wins; a step offers more than one because an undo can remove the thing
423/// its edit selected. None surviving means no selection, not a tool pointed
424/// at nothing.
425pub 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
432/// `None` when the document no longer holds what the selection names.
433fn 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    /// A moment past the coalescing window.
536    fn later(at: Duration) -> Duration {
537        at + COALESCE + Duration::from_millis(1)
538    }
539
540    /// Feed one state until the stack has had time to settle on it, as the
541    /// app does frame after frame with nothing changing.
542    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    /// The coalescing rule, which is the whole reason navigation does not
550    /// bury document edits: two camera moves inside the window are one press
551    /// to take back, a third outside it is a second.
552    #[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        // Two moves in quick succession: neither settles, so neither is an
559        // entry of its own.
560        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        // A third, well clear of the window, and settled in turn.
566        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    /// The other half of the same rule: a camera worked continuously — a
589    /// pinch, a wheel spun down — never settles, so it leaves one entry
590    /// rather than one per frame.
591    #[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    /// Entries of both kinds come back newest first, and an edit between
615    /// two view moves keeps its own turn.
616    #[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    /// Two edits inside the coalescing window are still two presses: the
659    /// window is a rule for *view* entries, and burying an edit under
660    /// another edit is the thing it exists to prevent.
661    #[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    /// Redo mirrors undo, and a new entry abandons the future — the one-stack
676    /// rule, which a view entry obeys exactly as a document one does.
677    #[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    /// A scope change is a view entry like a camera move: a `view` entry
703    /// holds camera state, and the scope is which drawing that camera is
704    /// pointed at.
705    #[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    /// A document whose trail came back from a log hands this stack a point
719    /// per entry, both ways and in the trail's order.
720    #[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    /// The selection rides with a state but is not itself a state: picking
769    /// something is how you reach an edit, not something to take back.
770    #[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    /// The selection-to-tool map, against a real document.
787    #[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    /// The fallback in action: the first candidate is gone, so the second
804    /// takes it — which is what stops an undo landing on nothing.
805    #[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    /// A route is picked at a point, not over its whole box, so restoring one
832    /// needs the anchor back — without it there is no honest place to put the
833    /// overlay, and the candidate is passed over.
834    #[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    /// A rename is punctuated like an edit, and the step back over it is a
864    /// rename — named by both names, in the order the rename made them.
865    #[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    /// A step is the costliest thing it crosses, and only a step between two
901    /// names renames anything.
902    #[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}