Skip to main content

blockworx/
history.rs

1//! The editor's undo stack: spec §7's 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 egui's [`Undoer`], not one of ours.** It is fed the current
10//! state every frame and decides for itself when a change has settled into an
11//! entry, which is exactly §7.2's coalescing rule with [`COALESCE`] as its
12//! `stable_time`: a camera worked continuously never settles, so a pinch
13//! leaves one entry rather than one per frame, and two moves inside the window
14//! are one press 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 F6's reopened undo
18//! depth comes from it — and a state names which of its positions the
19//! document stands on through [`Stood`]. This stack is the session-level
20//! interleaving over that trail, and its own emptiness on reload is spec
21//! §6.2's "viewing state does not survive a reload".
22
23use core::time::Duration;
24
25use blockworx_doc::{rev::Rev, trail::Trail};
26use blockworx_geom::Pos2;
27use egui::util::undoer::Undoer;
28
29use crate::{
30    canvas::Vantage,
31    path::BlockPath,
32    tools::{
33        SelectTool,
34        multi_pin_select::MultiPinSelect,
35        multi_select::MultiSelect,
36        resize_block::ResizeBlock,
37        tool::{Deletable, Tool, select_tool_for_anchor},
38    },
39    widget::drawing::Drawing,
40};
41/// §7.2: consecutive same-type view entries inside this window are one entry.
42/// It is the [`Undoer`]'s `stable_time`, so it is also what makes a gesture
43/// end — rather than a gesture frame — the point an entry lands.
44pub const COALESCE: Duration = Duration::from_millis(1500);
45
46/// How many states the stack remembers. egui's own default; past it the
47/// history panel is the surface, as it is for the log.
48const DEPTH: usize = 100;
49
50/// A selection, with the point a route was picked at.
51///
52/// Bundled because the anchor only means anything beside the thing it
53/// anchors: a route spans a wide box but is picked at one spot, and an
54/// anchor without a selection names nothing.
55#[derive(Clone, Debug, PartialEq)]
56pub struct Selection {
57    pub what: Deletable,
58    pub anchor: Option<Pos2>,
59}
60
61/// Which of the trail's positions the document stands on: the rev whose
62/// document the head currently holds (S6).
63///
64/// **Not the head rev.** A step authors a commit of its own, so the head
65/// moves *forward* when the document steps back; feeding it would make
66/// every undo look like a fresh state and the stack would grow instead of
67/// walking. What the trail tracks instead is where a step *left* the
68/// session, which is stable across a round trip — an undo and its redo
69/// return to the same `Stood` — and ordered along both halves of the
70/// trail, which is what makes a walk toward one terminate.
71#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default)]
72pub struct Stood(Rev);
73
74impl Stood {
75    pub fn of(trail: &Trail) -> Self {
76        Self(trail.standing())
77    }
78}
79
80/// What made a state, in the words §7.2's tooltip names it by.
81///
82/// Panning and zooming are one kind: a hand navigating with both would
83/// otherwise leave entries that alternate and so never coalesce.
84#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
85pub enum Moved {
86    #[default]
87    Camera,
88    Fit,
89    Focus,
90    Scope,
91    /// A document edit. Its words come from the commit, not from here.
92    Edit,
93}
94
95impl Moved {
96    /// What one press would take back, for a `view` entry. A `doc` entry is
97    /// named by its commit's own label instead.
98    pub fn label(self) -> &'static str {
99        match self {
100            Moved::Camera => "the camera move",
101            Moved::Fit => "zoom to fit",
102            Moved::Focus => "the focus",
103            Moved::Scope => "the change of scope",
104            Moved::Edit => "the edit",
105        }
106    }
107}
108
109/// One point on the stack: everywhere the editor was, as far as undo cares.
110///
111/// Equality is what the [`Undoer`] asks every frame to decide whether
112/// anything happened, so it covers exactly the camera, the scope and the
113/// document — §7.1's two kinds and nothing else. Two fields ride along
114/// outside it:
115///
116/// - the **selection**, because picking something is how you *reach* an edit;
117///   a step per selection would cost several presses to get back past one
118///   edit and Cmd+Z would stop meaning "take back what I did". It still comes
119///   back with the state that holds it.
120/// - the **label**, because a fit that lands the camera where it already was
121///   is not a step.
122#[derive(Clone, Debug)]
123pub struct State {
124    pub camera: Vantage,
125    pub scope: BlockPath,
126    pub stood: Stood,
127    pub moved: Moved,
128    pub selection: Option<Selection>,
129}
130
131impl PartialEq for State {
132    fn eq(&self, other: &Self) -> bool {
133        self.camera == other.camera && self.scope == other.scope && self.stood == other.stood
134    }
135}
136
137/// §7.1's two kinds of entry, told apart by whether taking the step back
138/// crosses a [`Stood`] boundary.
139#[derive(Clone, Copy, PartialEq, Eq, Debug)]
140pub enum Kind {
141    /// Authors an inverse rev at the head.
142    Doc,
143    /// Restores a camera and a scope. Touches no log.
144    View,
145}
146
147/// What one press of undo — or of redo — would do: the thing it names, and
148/// what it costs the log. Invariant 8's whole content.
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub struct Consequence {
151    pub target: String,
152    pub kind: Kind,
153}
154
155/// Which way a history step goes. Two variants, so "an edit is a history
156/// step" cannot be spelled.
157#[derive(Clone, Copy, PartialEq, Eq, Debug)]
158pub enum Direction {
159    Back,
160    Forward,
161}
162
163/// Whether a frame's own recording should happen. An undo or redo restores a
164/// state by definition, so the frame it runs in must not read the inverse
165/// commits it authored as fresh edits — which would push the step it just
166/// took straight back onto the stack.
167#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
168pub enum Recording {
169    #[default]
170    On,
171    Suspended,
172}
173
174pub struct UndoStack {
175    undoer: Undoer<State>,
176}
177
178impl UndoStack {
179    /// A stack holding nothing but where the session opens.
180    pub fn opening(at: &State) -> Self {
181        let mut stack = Self {
182            undoer: Undoer::with_settings(egui::util::undoer::Settings {
183                max_undos: DEPTH,
184                stable_time: COALESCE.as_secs_f32(),
185                ..Default::default()
186            }),
187        };
188        stack.undoer.add_undo(at);
189        stack
190    }
191
192    /// The stack a document that was replayed rather than edited opens on:
193    /// one point per entry of `trail`, both ways, in the trail's own order
194    /// — so F6's reopened undo depth is what the user sees from the first
195    /// frame.
196    ///
197    /// A reconstructed point has no camera or scope of its own: those are
198    /// session state and genuinely do not survive a restart (§6.2), so every
199    /// point holds the one the document opens at. That is also why the walk
200    /// they drive is a document walk and lands the camera where it already
201    /// is.
202    pub fn reconstructed(trail: &Trail, at: &State) -> Self {
203        let line = trail.standings();
204        let point = |depth: usize| State {
205            stood: Stood(line[depth]),
206            ..at.clone()
207        };
208        let mut stack = Self::opening(&point(0));
209        let past = trail.undo_depth();
210        let future = trail.redo_depth();
211        for depth in 1..=(past + future) {
212            stack.undoer.add_undo(&point(depth));
213        }
214        // Nothing can push a redo directly, so the future is walked back out
215        // of the past: each step moves one point across, and the last of them
216        // lands on the state the document actually stands in.
217        for depth in ((past + 1)..=(past + future)).rev() {
218            stack.undoer.undo(&point(depth));
219        }
220        stack
221    }
222
223    /// Offer the stack this frame's state. Whether it becomes an entry is the
224    /// [`Undoer`]'s call, and [`COALESCE`] is the rule it makes it by.
225    pub fn feed(&mut self, at: Duration, now: &State) {
226        self.undoer.feed_state(at.as_secs_f64(), now);
227    }
228
229    /// Punctuate the stack with an edit: the state either side of it becomes
230    /// an entry immediately, so two edits inside the coalescing window are
231    /// still two presses to take back. View entries coalesce; document edits
232    /// are what the coalescing exists to stop burying.
233    ///
234    /// The feed goes first because it is what drops the forward history the
235    /// edit has just invalidated — the trail has already dropped its own half.
236    pub fn edited(&mut self, at: Duration, before: &State, now: &State) {
237        self.undoer.feed_state(at.as_secs_f64(), now);
238        self.undoer.add_undo(before);
239        self.undoer.add_undo(now);
240    }
241
242    /// Pin where a step actually came to rest.
243    ///
244    /// A step that framed what it changed does not land on the point it
245    /// popped — the camera moved to bring the change into sight — and the
246    /// stack has to hold *that*, or the next frame's [`Self::feed`] would
247    /// read the framing as a fresh move and abandon the future the step just
248    /// created. Framing nothing lands on the popped point exactly, and this
249    /// is then the no-op it should be.
250    pub fn landed(&mut self, at: &State) {
251        self.undoer.add_undo(at);
252    }
253
254    /// The state one step would land on, without taking it.
255    ///
256    /// egui's `Undoer` keeps its points private and offers no peek, so the
257    /// question is asked of a copy. That is the honest way to ask it: a
258    /// second stack kept alongside to answer it would be the drift this
259    /// module exists to avoid.
260    pub fn peek(&self, direction: Direction, now: &State) -> Option<State> {
261        let mut asked = UndoStack {
262            undoer: self.undoer.clone(),
263        };
264        asked.step(direction, now)
265    }
266
267    /// Take one step, and say where it lands.
268    pub fn step(&mut self, direction: Direction, now: &State) -> Option<State> {
269        match direction {
270            Direction::Back => self.undoer.undo(now).cloned(),
271            Direction::Forward => self.undoer.redo(now).cloned(),
272        }
273    }
274}
275
276/// The tool a restored selection resolves to — the one place that maps a
277/// selection back to the tool that holds it, so restoring and selecting
278/// cannot disagree about what "a block is selected" means.
279///
280/// `candidates` are tried in order and the first the document still holds
281/// wins; a step offers more than one because an undo can remove the thing
282/// its edit selected. None surviving means no selection, not a tool pointed
283/// at nothing.
284pub fn tool_for(data: &Drawing<'_>, candidates: &[&Selection]) -> Tool {
285    candidates
286        .iter()
287        .find_map(|selection| resolve(data, selection))
288        .unwrap_or_else(|| SelectTool.into())
289}
290
291/// `None` when the document no longer holds what the selection names.
292fn resolve(data: &Drawing<'_>, selection: &Selection) -> Option<Tool> {
293    match &selection.what {
294        Deletable::Shape(shape) => data
295            .shape(*shape)
296            .is_some()
297            .then(|| ResizeBlock::Selected { shape: *shape }.into()),
298        Deletable::Route(id) => {
299            let anchor = selection.anchor?;
300            data.auto_route(*id)
301                .is_some()
302                .then(|| crate::tools::EditRoute::Selected { id: *id, anchor }.into())
303        }
304        Deletable::Shapes(shapes) => {
305            let live: Vec<_> = shapes
306                .iter()
307                .copied()
308                .filter(|id| data.shape(*id).is_some())
309                .collect();
310            match live.len() {
311                0 => None,
312                1 => Some(ResizeBlock::Selected { shape: live[0] }.into()),
313                _ => Some(MultiSelect::Selected { shapes: live }.into()),
314            }
315        }
316        Deletable::Pins(pins) => {
317            let live: Vec<_> = pins
318                .iter()
319                .copied()
320                .filter(|id| data.pin_on_shape(*id).is_some())
321                .collect();
322            match live.len() {
323                0 => None,
324                1 => Some(select_tool_for_anchor(data, live[0])),
325                _ => Some(MultiPinSelect::Selected { pins: live }.into()),
326            }
327        }
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use blockworx_doc::fixtures::{block_id, rev};
335    use blockworx_geom::{Vec2, pos2, vec2};
336
337    fn vantage(x: f32) -> Vantage {
338        Vantage {
339            zoom: blockworx_paint::Zoom::unity(),
340            translation: vec2(x, 0.0),
341        }
342    }
343
344    fn state() -> State {
345        State {
346            camera: Vantage {
347                zoom: blockworx_paint::Zoom::unity(),
348                translation: Vec2::ZERO,
349            },
350            scope: BlockPath::empty(),
351            stood: Stood::default(),
352            moved: Moved::default(),
353            selection: None,
354        }
355    }
356
357    fn looking_at(x: f32) -> State {
358        State {
359            camera: vantage(x),
360            ..state()
361        }
362    }
363
364    fn inside(n: u32) -> State {
365        let mut scope = BlockPath::empty();
366        scope.push(block_id(n));
367        State { scope, ..state() }
368    }
369
370    fn edited(depth: u64) -> State {
371        State {
372            stood: Stood(rev(depth)),
373            moved: Moved::Edit,
374            ..state()
375        }
376    }
377
378    fn shape(n: u32) -> Selection {
379        Selection {
380            what: Deletable::Shape(crate::shape::ShapeId::Rect(block_id(n))),
381            anchor: None,
382        }
383    }
384
385    /// A moment past the coalescing window.
386    fn later(at: Duration) -> Duration {
387        at + COALESCE + Duration::from_millis(1)
388    }
389
390    /// Feed one state until the stack has had time to settle on it, as the
391    /// app does frame after frame with nothing changing.
392    fn settle(stack: &mut UndoStack, at: Duration, now: &State) -> Duration {
393        stack.feed(at, now);
394        let at = later(at);
395        stack.feed(at, now);
396        at
397    }
398
399    /// §7.2's coalescing rule, which is the whole reason navigation does not
400    /// bury document edits: two camera moves inside the window are one press
401    /// to take back, a third outside it is a second.
402    #[test]
403    fn two_camera_moves_inside_the_window_are_one_entry_and_a_third_outside_is_a_second() {
404        let start = state();
405        let mut stack = UndoStack::opening(&start);
406        let mut at = Duration::ZERO;
407
408        // Two moves in quick succession: neither settles, so neither is an
409        // entry of its own.
410        stack.feed(at, &looking_at(10.0));
411        at += COALESCE / 3;
412        stack.feed(at, &looking_at(20.0));
413        at = settle(&mut stack, at, &looking_at(20.0));
414
415        // A third, well clear of the window, and settled in turn.
416        stack.feed(at, &looking_at(30.0));
417        let at = settle(&mut stack, at, &looking_at(30.0));
418        assert!(at > COALESCE, "precondition: the third move is a new entry");
419
420        let here = looking_at(30.0);
421        let first = stack.step(Direction::Back, &here).expect("one step back");
422        assert_eq!(
423            first.camera,
424            vantage(20.0),
425            "the third move was buried in the pair before it",
426        );
427        let second = stack.step(Direction::Back, &first).expect("two steps back");
428        assert_eq!(
429            second.camera, start.camera,
430            "the two moves inside the window cost two presses instead of one",
431        );
432        assert!(
433            stack.peek(Direction::Back, &second).is_none(),
434            "the stack held more entries than the moves that were made",
435        );
436    }
437
438    /// The other half of the same rule: a camera worked continuously — a
439    /// pinch, a wheel spun down — never settles, so it leaves one entry
440    /// rather than one per frame.
441    #[test]
442    fn a_camera_worked_without_pause_leaves_one_entry() {
443        let start = state();
444        let mut stack = UndoStack::opening(&start);
445        let mut at = Duration::ZERO;
446        for step in 1..=40 {
447            at += COALESCE / 4;
448            stack.feed(at, &looking_at(step as f32));
449        }
450        let here = looking_at(40.0);
451        settle(&mut stack, at, &here);
452
453        let back = stack.step(Direction::Back, &here).expect("one step back");
454        assert_eq!(
455            back.camera, start.camera,
456            "a gesture's frames became entries of their own",
457        );
458        assert!(
459            stack.peek(Direction::Back, &back).is_none(),
460            "and only the one entry"
461        );
462    }
463
464    /// The interleaving §7.1 asks for: entries of both kinds come back
465    /// newest first, and an edit between two view moves keeps its own turn.
466    #[test]
467    fn doc_and_view_entries_come_back_in_the_order_they_were_made() {
468        let start = state();
469        let mut stack = UndoStack::opening(&start);
470        let at = Duration::ZERO;
471
472        let moved = looking_at(10.0);
473        let at = settle(&mut stack, at, &moved);
474        let one_edit = State {
475            camera: moved.camera,
476            ..edited(1)
477        };
478        stack.edited(at, &moved, &one_edit);
479        let two_edits = State {
480            camera: moved.camera,
481            ..edited(2)
482        };
483        stack.edited(at, &one_edit, &two_edits);
484        let wandered = State {
485            camera: vantage(20.0),
486            ..two_edits.clone()
487        };
488        let _ = settle(&mut stack, at, &wandered);
489
490        let mut here = wandered;
491        let mut walked = Vec::new();
492        while let Some(back) = stack.step(Direction::Back, &here) {
493            walked.push((back.stood, back.camera));
494            here = back;
495        }
496        assert_eq!(
497            walked,
498            vec![
499                (Stood(rev(2)), vantage(10.0)),
500                (Stood(rev(1)), vantage(10.0)),
501                (Stood(rev(0)), vantage(10.0)),
502                (Stood(rev(0)), start.camera),
503            ],
504            "the walk skipped a kind or reordered the two",
505        );
506    }
507
508    /// Two edits inside the coalescing window are still two presses: the
509    /// window is §7.2's rule for *view* entries, and burying an edit under
510    /// another edit is the thing it exists to prevent.
511    #[test]
512    fn edits_inside_the_coalescing_window_do_not_merge() {
513        let start = state();
514        let mut stack = UndoStack::opening(&start);
515        let at = Duration::ZERO;
516        let one = edited(1);
517        let two = edited(2);
518        stack.edited(at, &start, &one);
519        stack.edited(at + COALESCE / 10, &one, &two);
520
521        let back = stack.step(Direction::Back, &two).expect("one step back");
522        assert_eq!(back.stood, Stood(rev(1)), "one press took back two edits");
523    }
524
525    /// Redo mirrors undo, and a new entry abandons the future — the one-stack
526    /// rule, which a view entry obeys exactly as a document one does.
527    #[test]
528    fn redo_returns_to_where_undo_found_us_and_a_new_move_abandons_it() {
529        let start = state();
530        let mut stack = UndoStack::opening(&start);
531        let moved = looking_at(10.0);
532        let at = settle(&mut stack, Duration::ZERO, &moved);
533
534        let back = stack.step(Direction::Back, &moved).expect("a step back");
535        assert_eq!(back.camera, start.camera);
536        assert!(
537            stack.peek(Direction::Forward, &back).is_some(),
538            "the move is not on the forward half"
539        );
540        let forward = stack.step(Direction::Forward, &back).expect("a step on");
541        assert_eq!(forward.camera, moved.camera);
542
543        let back = stack.step(Direction::Back, &forward).expect("a step back");
544        let elsewhere = looking_at(99.0);
545        stack.feed(later(at), &elsewhere);
546        assert!(
547            stack.peek(Direction::Forward, &back).is_none(),
548            "a fresh move outlived the future it forked away from",
549        );
550    }
551
552    /// A scope change is a view entry like a camera move — §7.1's table says
553    /// `view` entries hold "camera state", and the scope is which drawing
554    /// that camera is pointed at.
555    #[test]
556    fn a_scope_change_is_a_view_entry() {
557        let start = state();
558        let mut stack = UndoStack::opening(&start);
559        let deeper = inside(7);
560        assert_ne!(deeper.scope, start.scope, "precondition: the scope moved");
561        settle(&mut stack, Duration::ZERO, &deeper);
562
563        let back = stack.step(Direction::Back, &deeper).expect("a step back");
564        assert_eq!(back.scope, start.scope);
565        assert_eq!(back.stood, start.stood, "a scope change touched the log");
566    }
567
568    /// F6's editor half: a document whose trail came back from a log hands
569    /// this stack a point per entry, both ways and in the trail's order.
570    #[test]
571    fn a_reconstructed_stack_walks_the_trail_it_came_from() {
572        use blockworx_doc::trail::JournalAs;
573
574        let mut trail = Trail::default();
575        for at in 1..=3 {
576            trail.record(rev(at), JournalAs::Edit);
577        }
578        trail.record(rev(4), JournalAs::Undo { of: rev(3) });
579        assert_eq!(
580            (trail.undo_depth(), trail.redo_depth()),
581            (2, 1),
582            "precondition: the trail has depth both ways to reproduce",
583        );
584
585        let here = State {
586            stood: Stood::of(&trail),
587            ..state()
588        };
589        let mut stack = UndoStack::reconstructed(&trail, &here);
590        assert!(
591            stack.peek(Direction::Back, &here).is_some(),
592            "the reopened depth was not offered"
593        );
594        assert!(
595            stack.peek(Direction::Forward, &here).is_some(),
596            "the forward half was dropped"
597        );
598
599        let forward = stack.step(Direction::Forward, &here).expect("a step on");
600        assert_eq!(
601            forward.stood,
602            Stood(rev(3)),
603            "redo did not reach the trail's own future",
604        );
605        let mut walking = forward;
606        let mut stops = Vec::new();
607        while let Some(back) = stack.step(Direction::Back, &walking) {
608            stops.push(back.stood);
609            walking = back;
610        }
611        assert_eq!(
612            stops,
613            vec![Stood(rev(2)), Stood(rev(1)), Stood(rev(0))],
614            "the walk back does not match the trail it was built from",
615        );
616    }
617
618    /// The selection rides with a state but is not itself a state: picking
619    /// something is how you reach an edit, not something to take back.
620    #[test]
621    fn a_selection_change_is_not_an_entry_of_its_own() {
622        let start = state();
623        let mut stack = UndoStack::opening(&start);
624        let picked = State {
625            selection: Some(shape(4)),
626            ..state()
627        };
628        assert_eq!(picked, start, "the selection is outside the comparison");
629        settle(&mut stack, Duration::ZERO, &picked);
630        assert!(
631            stack.peek(Direction::Back, &picked).is_none(),
632            "picking something became something to take back",
633        );
634    }
635
636    /// The selection-to-tool map, against a real document.
637    #[test]
638    fn a_restored_selection_resolves_to_the_tool_that_holds_it() {
639        use crate::widget::test_fixtures::two_blocks_with_a_routed_waypoint;
640        let mut scene = two_blocks_with_a_routed_waypoint();
641        let drawing = scene.drawing();
642
643        assert!(
644            matches!(tool_for(&drawing, &[&shape(1)]), Tool::ResizeBlock(_)),
645            "a selected block restores to the tool that shows its overlay",
646        );
647        assert!(
648            matches!(tool_for(&drawing, &[]), Tool::Select(_)),
649            "no candidate restores to plain select",
650        );
651    }
652
653    /// The fallback in action: the first candidate is gone, so the second
654    /// takes it — which is what stops an undo landing on nothing.
655    #[test]
656    fn a_candidate_the_document_lost_gives_way_to_the_next() {
657        use crate::widget::test_fixtures::two_blocks_with_a_routed_waypoint;
658        let mut scene = two_blocks_with_a_routed_waypoint();
659        let drawing = scene.drawing();
660        let gone = shape(200);
661        assert!(
662            drawing
663                .shape(gone.what.shapes().expect("a shape")[0])
664                .is_none(),
665            "precondition: the document does not hold the first candidate",
666        );
667
668        assert!(
669            matches!(
670                tool_for(&drawing, &[&gone, &shape(1)]),
671                Tool::ResizeBlock(_)
672            ),
673            "the surviving candidate is taken",
674        );
675        assert!(
676            matches!(tool_for(&drawing, &[&gone]), Tool::Select(_)),
677            "and with none surviving, nothing is selected",
678        );
679    }
680
681    /// A route is picked at a point, not over its whole box, so restoring one
682    /// needs the anchor back — without it there is no honest place to put the
683    /// overlay, and the candidate is passed over.
684    #[test]
685    fn a_route_candidate_without_its_anchor_is_passed_over() {
686        use crate::widget::test_fixtures::two_blocks_with_a_routed_waypoint;
687        let mut scene = two_blocks_with_a_routed_waypoint();
688        let drawing = scene.drawing();
689        let route = drawing
690            .auto_routes()
691            .next()
692            .map(|(id, _)| id)
693            .expect("the fixture's route");
694
695        let anchored = Selection {
696            what: Deletable::Route(route),
697            anchor: Some(pos2(30.0, 30.0)),
698        };
699        assert!(
700            matches!(tool_for(&drawing, &[&anchored]), Tool::EditRoute(_)),
701            "with its anchor, a route restores to the route editor",
702        );
703        let anchorless = Selection {
704            anchor: None,
705            ..anchored
706        };
707        assert!(matches!(
708            tool_for(&drawing, &[&anchorless]),
709            Tool::Select(_)
710        ));
711    }
712}