Skip to main content

blockworx/
app.rs

1use blockworx_geom::{Pos2, Rect, vec2};
2use blockworx_paint::{Cursor, FontChoice, Scheme};
3
4use crate::canvas::convert::{IntoEgui as _, IntoGeom};
5// The browser has no files: every path in this module is on a native-only
6// flow (the container, the documents directory, the recent list).
7#[cfg(not(target_arch = "wasm32"))]
8use std::path::{Path, PathBuf};
9
10use blockworx_store::doc::Doc;
11use blockworx_store::record::Identity;
12
13use crate::io_pin_picker::{self, PinTypePick};
14use crate::kernel::session::{Consequences, viewed};
15use crate::kernel::{CameraWork, Framing, Glide, Refit, Session, Sighting};
16use crate::{
17    canvas::{CanvasChrome, View},
18    edit::naming::InterfaceLock,
19    grid::GridCell,
20    panels::{
21        overlay::{OpenPicker, RightClick, Selection, selection_overlay},
22        palette::{Palette, PaletteOutcome},
23    },
24    path::BlockPath,
25    preferences::Preferences,
26    role_picker::{self, RolePick},
27    shape::{ShapeId, ShapeRef},
28    theme::Role,
29    tools::{
30        commands::CommandSet,
31        tool::{Action, Deletable, ExportTo, ImageTarget, RoleTarget, ToolTrait},
32    },
33    widget::drawing::Drawing,
34};
35// The tool a document swap settles onto; a browser session has no container
36// to swap.
37#[cfg(not(target_arch = "wasm32"))]
38use crate::tools::{SelectTool, tool::Tool};
39use blockworx_doc::id::{BlockId, PinId};
40use blockworx_doc::repo::Repo;
41
42/// The frame clock, as the undo stack and the spotlight read it: seconds since
43/// the app started, and never before that.
44fn now(ctx: &egui::Context) -> core::time::Duration {
45    core::time::Duration::try_from_secs_f64(ctx.input(|i| i.time)).unwrap_or_default()
46}
47
48/// What the session opens on (D20).
49#[cfg(not(target_arch = "wasm32"))]
50#[derive(Default)]
51pub enum Opening {
52    /// The path a command line named: a `.bwx` container, which attaches, or
53    /// a document file, which opens as a scratch session.
54    Path(PathBuf),
55    /// Nothing was named, so a document is born: a container under a name of
56    /// three words in the documents directory, attached from its first edit.
57    Born,
58    /// Neither — a session with no file behind it. The browser has only this,
59    /// and so does a test driving the editor without a filesystem.
60    #[default]
61    Detached,
62}
63
64// On the web every field but the two editor flags is compiled out, leaving a
65// config that is nothing but flags — and a by-value parameter clippy wants
66// copied rather than moved.
67#[cfg_attr(target_arch = "wasm32", derive(Clone, Copy))]
68#[derive(Default)]
69pub struct AppConfig {
70    #[cfg(not(target_arch = "wasm32"))]
71    pub opening: Opening,
72    /// Where a new document's container is created — at startup and at
73    /// File ▸ New (D20). Injected rather than read off the environment down
74    /// in the flow, so a test can point it at a directory of its own.
75    #[cfg(not(target_arch = "wasm32"))]
76    pub documents: blockworx_store::naming::Documents,
77    /// Open the live theme editor in a second window and persist its result to
78    /// `theme.json` on exit.
79    pub theme_editor: bool,
80    /// Open the live font-size editor in a second window and persist its result
81    /// to `font_sizes.json` on exit.
82    pub font_editor: bool,
83}
84
85/// The eframe storage key the workspace panel's per-document state lives
86/// under, beside the preferences blob and the recent-files list.
87const WORKSPACES: &str = "workspaces";
88
89/// What separates the levels of a selection path in the status line — the
90/// mockup's own `join(" / ")`.
91const SELECTION_SEPARATOR: &str = "/";
92
93pub struct App {
94    /// The editor itself: the document, where this session is standing, what
95    /// it has selected, what one undo would take back. Every document read
96    /// and every write goes through it, and the canvas pass below is its
97    /// [`Session::canvas_frame`] over an egui `Painter`.
98    session: Session,
99    /// The file a *scratch* document was opened from, for the window title.
100    /// `None` on the web (no filesystem) and when nothing was there to open.
101    /// An attached container names itself.
102    #[cfg(not(target_arch = "wasm32"))]
103    opened: Option<String>,
104    /// Where the document this session opened came from, when it was opened
105    /// from an export that carried D19's provenance. Advisory and
106    /// session-only: the title block says it, nothing writes it, and saving
107    /// into a container does not carry it past this session.
108    #[cfg(not(target_arch = "wasm32"))]
109    opened_from: Option<blockworx_store::projection::Provenance>,
110    /// What this session has owned up to and the user has not yet
111    /// acknowledged: an error nobody can dismiss makes the editor unusable
112    /// (docs/ui-issues.md). Dropped with the document it was about.
113    failures: Vec<String>,
114    /// The containers the File menu offers to reopen, persisted through the
115    /// eframe storage DB.
116    #[cfg(not(target_arch = "wasm32"))]
117    recent: crate::file::RecentFiles,
118    /// Where File ▸ New puts the container it creates (D20).
119    #[cfg(not(target_arch = "wasm32"))]
120    documents: blockworx_store::naming::Documents,
121    /// The containers *this session* created that nobody has claimed: born
122    /// under a generated name, never committed to and never renamed. They
123    /// are removed on a clean exit, so launching and quitting leaves the
124    /// documents directory as it was, and they stay off the recent list
125    /// until they are claimed, so it does not fill with dead names.
126    #[cfg(not(target_arch = "wasm32"))]
127    unclaimed: Vec<PathBuf>,
128    /// The File menu's rename box, which has to survive the frames it is
129    /// open across.
130    #[cfg(not(target_arch = "wasm32"))]
131    rename_draft: String,
132    /// The channel an in-flight File-menu dialog delivers its pick on.
133    /// Polled each frame; `None` when no dialog is open.
134    #[cfg(not(target_arch = "wasm32"))]
135    pending_file: Option<crate::file::PickReceiver>,
136    /// User appearance preferences (theme, mode, font), persisted in the
137    /// eframe storage DB and applied live via [`Self::apply_preferences`].
138    pub preferences: Preferences,
139    /// The (theme, resolved-dark, font) last pushed to the egui context, so
140    /// appearance is only re-applied (a relayout) when the effective look changes.
141    applied_appearance: Option<(Scheme, bool, FontChoice)>,
142    /// The title last pushed to the viewport, so it is only re-sent when it
143    /// changes.
144    #[cfg(not(target_arch = "wasm32"))]
145    applied_title: String,
146    /// The head [`Self::refresh_projection`] is waiting out, and since when.
147    #[cfg(not(target_arch = "wasm32"))]
148    head_moved: Option<(blockworx_doc::rev::Rev, std::time::Instant)>,
149    canvas: View,
150    /// Whether the `--theme-editor` window is shown. Cleared when that window is
151    /// closed; gates whether `theme.json` is written on exit.
152    theme_editor: bool,
153    /// Whether the `--font-editor` window is shown. Cleared when that window is
154    /// closed; gates whether `font_sizes.json` is written on exit.
155    font_editor: bool,
156    /// Whether the app's toolbar icons have been registered with the canvas yet.
157    /// Done once, on the first frame (registration needs `egui::Context`).
158    images_loaded: bool,
159    /// The open selection popup, if any. Set by an [`Action`] and read one frame
160    /// later so the click that opened it isn't mistaken for a click-outside
161    /// dismiss. The popup anchors itself above the selection overlay via
162    /// [`Self::overlay_top_right`].
163    popup: Option<Popup>,
164    /// The selection overlay's top-right corner from the latest frame it was
165    /// drawn, used to anchor the accent popup directly above it. `None` when
166    /// nothing is selected.
167    overlay_top_right: Option<Pos2>,
168    /// The selected object's on-screen bounding box from this frame, in screen
169    /// pixels. Computed inside the canvas closure (where the painter transform and
170    /// the canvas's world→screen remap are available) and consumed by
171    /// `selection_overlay` after
172    /// the closure to place the overlay. `None` when nothing is selected.
173    selection_screen_bounds: Option<Rect>,
174    /// The command palette while open (Ctrl+K toggles it).
175    palette: Option<Palette>,
176    /// The channel an in-flight import dialog delivers its picked file on.
177    /// Polled each frame; `None` when no import is pending.
178    pending_import: Option<ImportReceiver>,
179    /// The channel an in-flight image dialog delivers its pick on, and what
180    /// the session asked for it. Polled each frame; `None` when no image is
181    /// pending.
182    pending_image: Option<(ImageTarget, ImageReceiver)>,
183    /// Which view the navigator is showing, whether it is showing at all, and how
184    /// wide it was left — for the document that is open (spec §8).
185    workspace: crate::shell::workspace::Workspace,
186    /// What the floating chrome left the canvas last frame (spec §2.1). Read
187    /// by the framing that has to land inside it, and by the overlay that has
188    /// to clamp into it.
189    safe: crate::shell::SafeArea,
190    /// The same, for every document this session has opened. Persisted
191    /// through the eframe storage DB beside the recent-files list, so a
192    /// document reopens with the panel it was left with.
193    workspaces: std::collections::BTreeMap<String, crate::shell::workspace::Workspace>,
194    /// The history panel's search box (§8.1). Kept here rather than in
195    /// egui's transient store because a query the user typed must survive
196    /// the panel being collapsed and reopened.
197    history_search: String,
198}
199
200/// A selection popup and the selection it acts on. At most one is open at a
201/// time, so opening either closes the other.
202enum Popup {
203    /// The accent-color picker, for one block, port, route, area, or text box.
204    Role(RoleTarget),
205    /// The pin-type (I/O style) picker, for the pins it retypes (a single pin, a
206    /// pin group, or a port's pin).
207    PinType(Vec<PinId>),
208}
209
210impl Popup {
211    fn picker(&self) -> OpenPicker {
212        match self {
213            Popup::Role(_) => OpenPicker::Role,
214            Popup::PinType(_) => OpenPicker::PinType,
215        }
216    }
217}
218
219/// What an import file dialog delivers: the picked file's name and bytes, or
220/// `None` if the user cancelled.
221type ImportReceiver = std::sync::mpsc::Receiver<Option<(String, Vec<u8>)>>;
222
223/// What an image dialog delivers: the picked artwork, or `None` if the user
224/// cancelled or the file could not be read.
225type ImageReceiver = std::sync::mpsc::Receiver<Option<blockworx_doc::block_model::Asset>>;
226
227/// How the view spells the session's [`Glide`].
228fn glided(glide: Glide) -> crate::canvas::Framing {
229    match glide {
230        Glide::Eased => crate::canvas::Framing::Animated,
231        Glide::Snap => crate::canvas::Framing::Immediate,
232    }
233}
234
235/// The cursor to actually publish for the active tool: the tool's requested
236/// cursor while the pointer is over the canvas, and none otherwise so the tool
237/// cursor never overrides an overlay's own cursor.
238fn effective_cursor(tool_cursor: Option<Cursor>, pointer: PointerOver) -> Option<Cursor> {
239    match pointer {
240        PointerOver::Canvas => tool_cursor,
241        PointerOver::Elsewhere => None,
242    }
243}
244
245/// Where the pointer sits: over the canvas, or over an overlay (which owns its
246/// own cursor).
247#[derive(Clone, Copy, PartialEq, Eq)]
248enum PointerOver {
249    Canvas,
250    Elsewhere,
251}
252
253/// The accent role `target` carries now, or `None` when it has none.
254fn current_role(drawing: &Drawing<'_>, target: RoleTarget) -> Option<u8> {
255    use crate::edit::lower::accent_from_role;
256    match target {
257        RoleTarget::Block(rid) => drawing.block(rid).and_then(|b| accent_from_role(b.role)),
258        RoleTarget::Port(pid) => match drawing.shape(ShapeId::Port(pid)) {
259            Some(ShapeRef::Port(port)) => accent_from_role(port.pin.port_accent),
260            _ => None,
261        },
262        RoleTarget::Route(rid) => drawing
263            .auto_route(rid)
264            .and_then(|wire| accent_from_role(wire.route.role)),
265        RoleTarget::Area(cid) => match drawing.shape(ShapeId::Area(cid)) {
266            Some(ShapeRef::Area(area)) => accent_from_role(area.role),
267            _ => None,
268        },
269        RoleTarget::Text(tid) => match drawing.shape(ShapeId::Text(tid)) {
270            Some(ShapeRef::Text(text)) => accent_from_role(text.text.role),
271            _ => None,
272        },
273    }
274}
275
276/// The most recent paste event's text this frame, if any.
277fn latest_paste(ctx: &egui::Context) -> Option<String> {
278    ctx.input(|i| {
279        i.events.iter().rev().find_map(|e| match e {
280            egui::Event::Paste(s) => Some(s.clone()),
281            _ => None,
282        })
283    })
284}
285
286/// The arrow key pressed this frame, as a one-cell nudge of the selection.
287fn arrow_nudge(ctx: &egui::Context) -> Option<Action> {
288    use egui::{Key, Modifiers};
289    let (dx, dy) = ctx.input_mut(|i| {
290        if i.consume_key(Modifiers::NONE, Key::ArrowLeft) {
291            Some((-1, 0))
292        } else if i.consume_key(Modifiers::NONE, Key::ArrowRight) {
293            Some((1, 0))
294        } else if i.consume_key(Modifiers::NONE, Key::ArrowUp) {
295            Some((0, -1))
296        } else if i.consume_key(Modifiers::NONE, Key::ArrowDown) {
297            Some((0, 1))
298        } else {
299            None
300        }
301    })?;
302    Some(Action::Nudge { dx, dy })
303}
304
305/// The role the picker's "no accent" swatch shows for `target`: its own
306/// un-accented stroke.
307fn unaccented_role(target: RoleTarget) -> Role {
308    match target {
309        RoleTarget::Area(_) => Role::AreaStroke,
310        RoleTarget::Text(_) => Role::TextBoxStroke,
311        _ => Role::AccentDefault,
312    }
313}
314
315#[cfg(not(target_arch = "wasm32"))]
316/// A repo whose one commit creates everything `doc` holds, under the ids
317/// the file gave them. The one door a document takes into the editor now:
318/// F5's courtesy file open comes through here, and a document the fold
319/// refuses opens empty rather than taking the editor down.
320fn seeded_repo(doc: &blockworx_doc::document::Document, label: &str) -> Repo {
321    let commits: Vec<_> = doc.creating_commit(label).into_iter().collect();
322    Repo::folding(&commits).unwrap_or_else(|e| {
323        tracing::error!("{label} will not seed a repo: {e}");
324        Repo::default()
325    })
326}
327
328/// What opening a plain document file came back with.
329#[cfg(not(target_arch = "wasm32"))]
330enum Loaded {
331    /// The file opened: this is what it held, under this name, and — for an
332    /// export — where it says it came from (D19). Boxed, so the enum is the
333    /// size of the sentence it carries rather than of a document.
334    Document {
335        repo: Box<Repo>,
336        name: String,
337        from: Option<blockworx_store::projection::Provenance>,
338    },
339    /// Nothing was there to open, which is not a complaint.
340    Nothing,
341    /// It was there, would not open, and this says why.
342    Failed(String),
343}
344
345/// Whether `path` names a document in a format this build no longer reads.
346/// The reader is gone, so the honest answer is a refusal naming the format —
347/// not a JSON parse failure over a file that was never JSON.
348#[cfg(not(target_arch = "wasm32"))]
349fn names_a_retired_format(path: &std::path::Path) -> bool {
350    path.extension()
351        .is_some_and(|ext| ext.eq_ignore_ascii_case("kdl"))
352}
353
354#[cfg(not(target_arch = "wasm32"))]
355fn retired_format_notice(name: &str) -> String {
356    format!("{name} is in the retired KDL document format, which this build no longer reads")
357}
358
359/// A plain document file — an export, or a hand-written one — read into a
360/// fresh repo and named for the window title. Nothing there opens blank
361/// without complaint (F9 — a boot invents no content the user never
362/// authored); a read or parse failure comes back as [`Loaded::Failed`] and
363/// the session opens blank around it, since refusing to start helps nobody.
364#[cfg(not(target_arch = "wasm32"))]
365fn open_document_file(path: &std::path::Path) -> Loaded {
366    if !path.is_file() {
367        return Loaded::Nothing;
368    }
369    let name = path
370        .file_name()
371        .map_or_else(String::new, |n| n.to_string_lossy().into_owned());
372    if names_a_retired_format(path) {
373        return Loaded::Failed(retired_format_notice(&name));
374    }
375    let src = match std::fs::read_to_string(path) {
376        Ok(src) => src,
377        Err(e) => return Loaded::Failed(format!("Failed to open {}: {e}", path.display())),
378    };
379    match blockworx_store::document_file::parse(&src, &name) {
380        Ok(doc) => Loaded::Document {
381            repo: Box::new(seeded_repo(&doc, &format!("Opened {name}"))),
382            from: blockworx_store::projection::exported_stamp_in(&src).and_then(|s| s.provenance),
383            name,
384        },
385        Err(e) => {
386            // The console gets the spans and the offending source; the canvas
387            // gets the sentence, since a rendered diagnostic is a wall of text
388            // in a notice.
389            tracing::error!("Failed to open {}:\n{e:?}", path.display());
390            Loaded::Failed(format!("Failed to open {}: {e}", path.display()))
391        }
392    }
393}
394
395/// What the session starts on: the document, the name the window title says
396/// was opened, the container this startup created if it made one, and
397/// whatever would not open on the way — which the canvas says out loud
398/// rather than only the console.
399#[cfg(not(target_arch = "wasm32"))]
400struct Startup {
401    doc: Doc,
402    opened: Option<String>,
403    from: Option<blockworx_store::projection::Provenance>,
404    born: Option<PathBuf>,
405    failure: Option<String>,
406}
407
408#[cfg(not(target_arch = "wasm32"))]
409impl Startup {
410    /// A session with nothing behind it — the blank canvas D20 starts on
411    /// when there is nowhere to be born.
412    fn detached() -> Self {
413        Startup {
414            doc: Doc::default(),
415            opened: None,
416            from: None,
417            born: None,
418            failure: None,
419        }
420    }
421}
422
423/// Open whatever the session was pointed at. A document born here is the
424/// no-argument case (D20).
425#[cfg(not(target_arch = "wasm32"))]
426fn open_startup(opening: Opening, documents: &blockworx_store::naming::Documents) -> Startup {
427    match opening {
428        Opening::Path(path) => open_startup_path(&path),
429        Opening::Born => born_attached(documents),
430        Opening::Detached => Startup::detached(),
431    }
432}
433
434/// What the toast says a Save-as did. Two sentences, because two things
435/// happened: at the head the document has a new home, and under the lens a
436/// *rev* of it does — and which one it was is the thing the user needs to
437/// read back (R37, R38).
438#[cfg(not(target_arch = "wasm32"))]
439fn saved_as(root: &Path, scope: crate::file::SaveScope) -> String {
440    let named = crate::file::document_name(root);
441    match scope {
442        crate::file::SaveScope::Whole => format!("Saved as {named}"),
443        crate::file::SaveScope::Through(at) => {
444            format!("Saved through rev {} as {named}", at.get())
445        }
446    }
447}
448
449/// D20's born-attached start: a container of its own, so append-on-commit
450/// holds from the first edit. A container that cannot be created is a
451/// notice and a scratch session, never a refusal to start.
452#[cfg(not(target_arch = "wasm32"))]
453fn born_attached(documents: &blockworx_store::naming::Documents) -> Startup {
454    match documents.create(blockworx_store::naming::entropy) {
455        Ok(store) => Startup {
456            born: Some(store.root().to_path_buf()),
457            doc: Doc::attached(store),
458            opened: None,
459            from: None,
460            failure: None,
461        },
462        Err(failure) => Startup {
463            failure: Some(failure.notice()),
464            ..Startup::detached()
465        },
466    }
467}
468
469/// What a named path opens: a container attached, or — for a plain document
470/// file, and for anything that will not open — a scratch session, which
471/// persists nothing.
472///
473/// The plain-file half is the command line's alone (R53). Nothing in the
474/// chrome offers to open a `document.json`: it is the projection beside a
475/// log, carrying neither that log nor the assets, so opening one would hand
476/// the user half a diagram wearing its name. A developer naming one on the
477/// command line has asked for exactly that, and is told what they got by the
478/// title bar's "[nothing persisted]".
479#[cfg(not(target_arch = "wasm32"))]
480fn open_startup_path(path: &std::path::Path) -> Startup {
481    let mut failure = None;
482    if crate::file::names_a_container(path) {
483        match crate::file::open_container(path) {
484            Ok(store) => {
485                if let Some(reason) = store.read_only_reason() {
486                    tracing::warn!("{} opened read-only: {reason}", path.display());
487                }
488                return Startup {
489                    doc: Doc::attached(store),
490                    ..Startup::detached()
491                };
492            }
493            Err(e) => {
494                failure = Some(format!("Failed to open {}: {e}", path.display()));
495            }
496        }
497    }
498    let (repo, opened, from, load_failure) = match open_document_file(path) {
499        Loaded::Document { repo, name, from } => (*repo, Some(name), from, None),
500        Loaded::Nothing => (Repo::default(), None, None, None),
501        Loaded::Failed(why) => (Repo::default(), None, None, Some(why)),
502    };
503    Startup {
504        doc: Doc::scratch(repo),
505        opened,
506        from,
507        born: None,
508        failure: failure.or(load_failure),
509    }
510}
511
512impl App {
513    pub fn new(config: AppConfig) -> Self {
514        let AppConfig {
515            #[cfg(not(target_arch = "wasm32"))]
516            opening,
517            #[cfg(not(target_arch = "wasm32"))]
518            documents,
519            theme_editor,
520            font_editor,
521        } = config;
522        #[cfg(not(target_arch = "wasm32"))]
523        let Startup {
524            doc,
525            opened,
526            from,
527            born,
528            failure,
529        } = open_startup(opening, &documents);
530        // The browser has no filesystem; open on an empty document.
531        #[cfg(target_arch = "wasm32")]
532        let doc = Doc::default();
533
534        let app = Self {
535            session: Session::opening(doc, Identity::from_environment()),
536            #[cfg(not(target_arch = "wasm32"))]
537            opened,
538            #[cfg(not(target_arch = "wasm32"))]
539            opened_from: from,
540            #[cfg(not(target_arch = "wasm32"))]
541            failures: failure.into_iter().collect(),
542            #[cfg(target_arch = "wasm32")]
543            failures: Vec::new(),
544            #[cfg(not(target_arch = "wasm32"))]
545            recent: crate::file::RecentFiles::default(),
546            #[cfg(not(target_arch = "wasm32"))]
547            documents,
548            #[cfg(not(target_arch = "wasm32"))]
549            unclaimed: born.into_iter().collect(),
550            #[cfg(not(target_arch = "wasm32"))]
551            rename_draft: String::new(),
552            #[cfg(not(target_arch = "wasm32"))]
553            pending_file: None,
554            preferences: Preferences::default(),
555            applied_appearance: None,
556            // Seeded below, once the fields it quotes are in place.
557            #[cfg(not(target_arch = "wasm32"))]
558            applied_title: String::new(),
559            #[cfg(not(target_arch = "wasm32"))]
560            head_moved: None,
561            canvas: View::default(),
562            selection_screen_bounds: None,
563            palette: None,
564            theme_editor,
565            font_editor,
566            images_loaded: false,
567            popup: None,
568            overlay_top_right: None,
569            pending_import: None,
570            pending_image: None,
571            workspace: crate::shell::workspace::Workspace::default(),
572            safe: crate::shell::SafeArea::default(),
573            workspaces: std::collections::BTreeMap::new(),
574            history_search: String::new(),
575        };
576        // `main` opens the window with `window_title()`, so seeding from the
577        // same call is what leaves the first frame nothing to re-send.
578        #[cfg(not(target_arch = "wasm32"))]
579        let app = {
580            let mut app = app;
581            app.applied_title = app.window_title();
582            app
583        };
584        app
585    }
586
587    /// What a scratch document's source file is called — the one fact about
588    /// the window title and the document's name that belongs to the platform
589    /// rather than to the document.
590    // The browser has no file to have opened one from, so the answer there is
591    // a constant `None` — and the shape of the question is still the shell's.
592    #[cfg_attr(target_arch = "wasm32", expect(clippy::unused_self))]
593    fn opened_name(&self) -> Option<&str> {
594        #[cfg(not(target_arch = "wasm32"))]
595        {
596            self.opened.as_deref()
597        }
598        #[cfg(target_arch = "wasm32")]
599        {
600            None
601        }
602    }
603
604    #[cfg(not(target_arch = "wasm32"))]
605    pub fn window_title(&self) -> String {
606        self.session.window_title(self.opened_name())
607    }
608
609    fn document_name(&self) -> String {
610        self.session.document_name(self.opened_name())
611    }
612
613    /// Re-normalize everything that referenced the previous document after the
614    /// whole editor state is swapped out: the tool and popups may point at
615    /// absent shapes and the nav history at absent paths.
616    #[cfg(not(target_arch = "wasm32"))]
617    fn after_document_swap(&mut self, was: &str) {
618        self.session.tool = Tool::Select(SelectTool);
619        self.popup = None;
620        self.settle_workspace(was);
621        // The failures were about the document that just left.
622        self.failures.clear();
623        self.session.fit_view();
624    }
625
626    /// Export `format` for `block` — or the current view when `None`. The
627    /// image formats render the framed level (PNG rasterizes the SVG source
628    /// off-thread when it is written); JSON projects the document itself, so
629    /// the file holds exactly what is on screen.
630    ///
631    /// A block export makes that block the top of a standalone diagram carrying
632    /// only its subtree.
633    fn export(
634        &mut self,
635        ctx: &egui::Context,
636        format: crate::export::ExportFormat,
637        selection: Option<Vec<crate::shape::ShapeId>>,
638    ) {
639        let name = self.document_name();
640        let content = self.export_content(format, selection);
641        crate::export::spawn_export(ctx, crate::export::ExportPayload { name, content });
642    }
643
644    /// D19's rev export: the ordinary flattened-document export, run over
645    /// the rev copy at `at` instead of over the head, and delivered to the
646    /// clipboard or through the save dialog. It writes nothing, so a
647    /// read-only session — and a session looking at another rev entirely —
648    /// takes it.
649    fn export_rev(&self, ctx: &egui::Context, at: blockworx_doc::rev::Rev, to: ExportTo) {
650        let source = self.export_source(at);
651        let stamp = self.session.doc.stamp_at(at);
652        let content = if at == self.session.doc.repo().rev() {
653            blockworx_store::projection::export_text(self.session.doc.document(), stamp, source)
654        } else {
655            let Some(document) = self.session.document_at(at) else {
656                return;
657            };
658            blockworx_store::projection::export_text(&document, stamp, source)
659        };
660        match to {
661            ExportTo::Clipboard => ctx.copy_text(content),
662            ExportTo::File => crate::export::spawn_export(
663                ctx,
664                crate::export::ExportPayload {
665                    name: format!("{}-r{}", self.document_name(), at.get()),
666                    content: crate::export::ExportContent::Json(content),
667                },
668            ),
669        }
670    }
671
672    /// What an export this session writes says about where it came from
673    /// (D19), for the fold at `at`.
674    fn export_source(&self, at: blockworx_doc::rev::Rev) -> blockworx_store::projection::Source {
675        blockworx_store::projection::Source {
676            document: self.document_name(),
677            author: self.session.identity.name.clone(),
678            tags: self.session.doc.tags().of(at).to_vec(),
679        }
680    }
681
682    /// What an export writes, apart from the dialog that delivers a path —
683    /// so the bytes a user gets are the bytes a test can read.
684    fn export_content(
685        &mut self,
686        format: crate::export::ExportFormat,
687        selection: Option<Vec<crate::shape::ShapeId>>,
688    ) -> crate::export::ExportContent {
689        use crate::export::{ExportContent, ExportFormat};
690
691        // A selection export writes a private standalone document (the
692        // selection pasted into a fresh empty one); the current-view export
693        // writes the live one in place.
694        let selection_repo = selection.and_then(|shapes| self.selection_repo(&shapes));
695        match format {
696            // The whole-document export is the artifact D19 stamps; a selection
697            // export is an excerpt of shapes rather than the document at a rev,
698            // so it carries no rev to be provenance for.
699            ExportFormat::Json => ExportContent::Json(if let Some(repo) = selection_repo {
700                blockworx_store::document_file::to_json(repo.document())
701            } else {
702                let at = self.session.viewed_repo().rev();
703                let source = self.export_source(at);
704                let stamp = self.session.doc.stamp_at(at);
705                blockworx_store::projection::export_text(
706                    viewed(&self.session.doc, self.session.time_machine.as_ref()).document(),
707                    stamp,
708                    source,
709                )
710            }),
711            // A selection has no scope hierarchy to navigate, so
712            // [`crate::export::ExportScope::Selection`] does not offer PDF and
713            // there is no selection arm to write.
714            ExportFormat::Pdf => self.export_pdf(),
715            ExportFormat::Svg | ExportFormat::Png => {
716                let svg = if let Some(repo) = selection_repo {
717                    let mut index = blockworx_doc::document::DocIndex::default();
718                    let mut presentation = crate::presentation::Presentation::default();
719                    crate::export::level::render_svg(
720                        &self.session.theme,
721                        &self.text_layout(),
722                        index.view(repo.document()),
723                        &BlockPath::empty(),
724                        &mut presentation,
725                    )
726                } else {
727                    let doc =
728                        viewed(&self.session.doc, self.session.time_machine.as_ref()).document();
729                    crate::export::level::render_svg(
730                        &self.session.theme,
731                        &self.text_layout(),
732                        self.session.doc_index.view(doc),
733                        &self.session.path,
734                        &mut self.session.presentation,
735                    )
736                };
737                if format == ExportFormat::Png {
738                    ExportContent::Png(svg)
739                } else {
740                    ExportContent::Svg(svg)
741                }
742            }
743        }
744    }
745
746    /// The text engine an export lays its diagram out through: the session's
747    /// own typeface, through the backend the canvas draws with, so an export
748    /// breaks its lines exactly where the screen did (D2).
749    fn text_layout(&self) -> crate::canvas::EpaintLayout {
750        crate::canvas::EpaintLayout::new(self.preferences.font)
751    }
752
753    /// The whole viewed document as a PDF (D21). A failed export writes an
754    /// empty file rather than taking the session down with it, and says so
755    /// where the developer already is.
756    fn export_pdf(&mut self) -> crate::export::ExportContent {
757        let repo = self.session.viewed_repo();
758        let rev = repo.rev();
759        let provenance = self
760            .session
761            .doc
762            .stamp_at(rev)
763            .from(self.export_source(rev))
764            .provenance;
765        let doc = repo.document().clone();
766        let block = self.title_block();
767        let mut index = blockworx_doc::document::DocIndex::default();
768        let layout = self.text_layout();
769        let scene = crate::export::pdf::Scene {
770            document: index.view(&doc),
771            theme: &self.session.theme,
772            scheme: self.preferences.theme,
773            layout: &layout,
774            block,
775            provenance,
776        };
777        crate::export::ExportContent::Pdf(crate::export::pdf::export(scene).unwrap_or_else(|e| {
778            tracing::error!("Failed to export PDF: {e}");
779            Vec::new()
780        }))
781    }
782
783    /// A standalone one-commit repo holding just the selection: copy the
784    /// shapes, then paste them into a fresh empty document, whose root the
785    /// block path then views. Reuses the paste pipeline (id remapping, route
786    /// re-materialization). A repo rather than a bare document because the
787    /// projection's names come from the log (D11). `None` when nothing
788    /// copyable is selected.
789    fn selection_repo(&mut self, shapes: &[crate::shape::ShapeId]) -> Option<Repo> {
790        let clip = self.session.drawing().copy_selection(shapes)?;
791        let doc = blockworx_doc::document::Document::default();
792        let mut index = blockworx_doc::document::DocIndex::default();
793        let mut presentation = crate::presentation::Presentation::default();
794        // The scratch document this builds is the export's own, never the
795        // session's, so a read-only session still exports its selection.
796        let mut gesture = crate::gesture::Gesture::open(
797            crate::edit::describe::Label::verb("Export"),
798            blockworx_store::doc::Writability::Writable,
799        );
800        let path = BlockPath::empty();
801        // A document of its own, so nothing on the clipboard can be a cut
802        // coming home to it: the export always mints.
803        let into = blockworx_store::doc::DocumentNonce::mint();
804        Drawing::new(index.view(&doc), &path, &mut presentation, &mut gesture)
805            .paste_snapshot(&clip, into, None);
806        let commit = gesture.seal("Exported a selection".to_owned())?;
807        Repo::folding(&[commit])
808            .inspect_err(|refusal| {
809                tracing::error!("the export document refused a paste: {refusal}");
810            })
811            .ok()
812    }
813
814    /// Push the appearance preferences — palette, egui visuals and fonts — to
815    /// `ctx`, but only when they actually change, since `set_fonts`/`set_visuals`
816    /// trigger a relayout. `System` mode resolves via the OS light/dark
817    /// preference, so a runtime OS theme flip re-resolves the theme variant here
818    /// too.
819    pub fn apply_preferences(&mut self, ctx: &egui::Context) {
820        // Cmd+Plus/Minus/0 belong to the canvas (zoom the diagram, fit the
821        // document). egui consumes those chords at the end of every frame to
822        // scale the whole UI instead, and nothing in the editor scales the UI
823        // any more (R47).
824        ctx.options_mut(|o| o.zoom_with_keyboard = false);
825        // §2.3 asks for one motion curve across the whole shell. egui has one
826        // knob for it, so this is the whole of the vocabulary — press states,
827        // hovers, tooltips and the navigator all move at the same rate.
828        ctx.all_styles_mut(|style| {
829            style.animation_time = crate::shell::glass::MOTION.as_secs_f32();
830        });
831        let system_dark = ctx.system_theme().map(|t| t == egui::Theme::Dark);
832        let dark = self.preferences.mode.is_dark(system_dark);
833        let theme = self.preferences.theme;
834        let font = self.preferences.font;
835        if self.applied_appearance != Some((theme, dark, font)) {
836            self.session.theme.set_palette(theme.palette(dark.into()));
837            ctx.set_visuals(crate::canvas::convert::visuals(
838                self.session.theme.palette(),
839            ));
840            ctx.set_fonts(crate::canvas::build_fonts(font));
841            self.applied_appearance = Some((theme, dark, font));
842        }
843    }
844
845    /// Restore persisted preferences from the eframe storage DB (written by
846    /// [`eframe::App::save`]). Called once at startup, before the first frame, so
847    /// the app opens with the user's saved theme/font/size. A malformed blob is
848    /// ignored, leaving the defaults in place.
849    pub fn restore_preferences(&mut self, storage: &dyn eframe::Storage) {
850        if let Some(s) = storage.get_string("preferences")
851            && let Ok(prefs) = serde_json::from_str(&s)
852        {
853            self.preferences = prefs;
854        }
855        if let Some(s) = storage.get_string(WORKSPACES)
856            && let Ok(workspaces) = serde_json::from_str(&s)
857        {
858            self.workspaces = workspaces;
859            let key = self.workspace_key();
860            self.workspace = self.workspaces.get(&key).copied().unwrap_or_default();
861        }
862        // A stored profile name (D9) overrides what the environment says
863        // this user is called, so the identity is re-read here rather than
864        // only at construction.
865        self.session.identity = self.preferences.identity();
866        #[cfg(not(target_arch = "wasm32"))]
867        {
868            self.recent = crate::file::RecentFiles::restore(storage);
869            // A container opened from the command line belongs at the front
870            // of the list too — the restore above would otherwise bury it.
871            // One this session invented does not: it earns its place by
872            // being written in or renamed (D20).
873            if let Some(root) = self.attached_root()
874                && !self.unclaimed.contains(&root)
875            {
876                self.recent.remember(&root);
877            }
878        }
879    }
880
881    /// The container this session is attached to, if it is.
882    #[cfg(not(target_arch = "wasm32"))]
883    fn attached_root(&self) -> Option<PathBuf> {
884        self.session.doc.container_root().map(Path::to_path_buf)
885    }
886
887    /// Re-title the window when what it says has changed under it: eframe
888    /// sets the title once at startup.
889    #[cfg(not(target_arch = "wasm32"))]
890    fn apply_window_title(&mut self, ctx: &egui::Context) {
891        let title = self.window_title();
892        if title != self.applied_title {
893            ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
894            self.applied_title = title;
895        }
896    }
897
898    /// Write the current role → base mapping to the source-tree `theme.json` so
899    /// the next build embeds it via `include_str!` (see [`Theme::from_embedded`](crate::theme::Theme::from_embedded)).
900    /// Targeting `CARGO_MANIFEST_DIR` makes this independent of the working
901    /// directory. Only called when the theme editor is active, so ordinary runs
902    /// never touch the file.
903    #[cfg_attr(target_arch = "wasm32", allow(dead_code, clippy::unused_self))]
904    fn save_theme(&self) {
905        // Dev-only editor feature that writes into the source tree; there is no
906        // source tree (or filesystem) on the web, so it is a no-op there.
907        #[cfg(not(target_arch = "wasm32"))]
908        {
909            let path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/theme/theme.json");
910            match serde_json::to_string_pretty(&self.session.theme.overrides()) {
911                Ok(s) => {
912                    if let Err(e) = blockworx_store::atomic::write_atomically(
913                        std::path::Path::new(path),
914                        s.as_bytes(),
915                    ) {
916                        tracing::error!("Failed to write {path}: {e}");
917                    }
918                }
919                Err(e) => tracing::error!("Failed to serialize theme: {e}"),
920            }
921        }
922    }
923
924    /// Write the current canvas font sizes to the source-tree `font_sizes.json`
925    /// so the next build embeds them via `include_str!` (see
926    /// [`Theme::from_embedded`](crate::theme::Theme::from_embedded)). Like [`Self::save_theme`], this targets
927    /// `CARGO_MANIFEST_DIR` and is only called when the font editor is active, so
928    /// ordinary runs never touch the file.
929    #[cfg_attr(target_arch = "wasm32", allow(dead_code, clippy::unused_self))]
930    fn save_font_sizes(&self) {
931        // See `save_theme`: dev-only source-tree write, a no-op on the web.
932        #[cfg(not(target_arch = "wasm32"))]
933        {
934            let path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/theme/font_sizes.json");
935            match serde_json::to_string_pretty(&self.session.theme.font_sizes()) {
936                Ok(s) => {
937                    if let Err(e) = blockworx_store::atomic::write_atomically(
938                        std::path::Path::new(path),
939                        s.as_bytes(),
940                    ) {
941                        tracing::error!("Failed to write {path}: {e}");
942                    }
943                }
944                Err(e) => tracing::error!("Failed to serialize font sizes: {e}"),
945            }
946        }
947    }
948
949    /// This frame's selection popup, driven from state set on a previous frame
950    /// (so the click that opened it isn't read as a click-outside dismiss).
951    /// Returns which picker was open on entry: the toolbar's Accent/I/O buttons
952    /// show active from it, and it has to be captured before a dismissal here
953    /// clears it, or the dismissing click reads as a request to reopen.
954    /// Returns the picker that was open, for the chrome, and whatever it
955    /// reported — a pick is dispatched with the frame's other actions rather
956    /// than written here, so it takes the same road as the rest.
957    fn show_popups(&mut self, ctx: &egui::Context) -> (Option<OpenPicker>, Option<Action>) {
958        let was_open = self.popup.as_ref().map(Popup::picker);
959        // A popup anchors above the selection overlay, whose corner was captured
960        // last frame; with no overlay there is nowhere to put it.
961        let Some(corner) = self.overlay_top_right else {
962            return (was_open, None);
963        };
964        let at = corner - vec2(0.0, crate::grid::GRID_SIZE);
965        let (still_open, picked) = match self.popup.take() {
966            Some(Popup::Role(target)) => self.show_role_picker(ctx, at, target),
967            Some(Popup::PinType(pins)) => self.show_pin_type_picker(ctx, at, pins),
968            None => (None, None),
969        };
970        self.popup = still_open;
971        (was_open, picked)
972    }
973
974    /// The accent-color picker for `target`. The targeted shape stays selected
975    /// behind it. `None` once it is dismissed.
976    fn show_role_picker(
977        &mut self,
978        ctx: &egui::Context,
979        at: Pos2,
980        target: RoleTarget,
981    ) -> (Option<Popup>, Option<Action>) {
982        let current = current_role(&self.session.drawing(), target);
983        let picked = match role_picker::show(
984            ctx,
985            at.egui(),
986            &self.session.theme,
987            current,
988            unaccented_role(target),
989        ) {
990            RolePick::Set(role) => Some(Action::SetRole { target, role }),
991            RolePick::Dismiss => return (None, None),
992            RolePick::None => None,
993        };
994        (Some(Popup::Role(target)), picked)
995    }
996
997    /// The pin-type (I/O style) picker for `pins`, which stay selected behind
998    /// it. The current cell is the pins' shared type, or none when they
999    /// disagree; a click sets every one of them. `None` once dismissed.
1000    fn show_pin_type_picker(
1001        &mut self,
1002        ctx: &egui::Context,
1003        at: Pos2,
1004        pins: Vec<PinId>,
1005    ) -> (Option<Popup>, Option<Action>) {
1006        let current = {
1007            let drawing = self.session.drawing();
1008            let mut kinds = pins
1009                .iter()
1010                .filter_map(|pin| drawing.pin_on_shape(*pin).map(|(_, p)| p.dir));
1011            kinds.next().filter(|first| kinds.all(|k| k == *first))
1012        };
1013        let picked = match io_pin_picker::show(ctx, at.egui(), current) {
1014            PinTypePick::Set(kind) => Some(Action::SetPinsKind {
1015                pins: pins.clone(),
1016                kind,
1017            }),
1018            PinTypePick::Dismiss => return (None, None),
1019            PinTypePick::None => None,
1020        };
1021        (Some(Popup::PinType(pins)), picked)
1022    }
1023
1024    /// The dev-only editor windows (`--theme-editor`, `--font-editor`). Both
1025    /// mutate `self.session.theme` in place, so they run before the theme is handed to
1026    /// this frame's canvas; closing either writes its file.
1027    fn show_editor_windows(&mut self, ctx: &egui::Context) {
1028        if self.theme_editor && crate::theme_editor::show(ctx, &mut self.session.theme) {
1029            self.theme_editor = false;
1030            self.save_theme();
1031        }
1032        if self.font_editor && crate::font_editor::show(ctx, &mut self.session.theme) {
1033            self.font_editor = false;
1034            self.save_font_sizes();
1035        }
1036    }
1037
1038    /// While an in-canvas text editor has focus, a plain-text paste should feed
1039    /// the `TextEdit`, but an OBJECT paste (our clipboard format) should behave
1040    /// like a normal deselected object paste instead of dumping JSON into the
1041    /// box. Consume that event here and surrender editor focus so the tool
1042    /// commits the box's current text this frame; the payload is returned to be
1043    /// pasted after the canvas pass.
1044    fn intercept_object_paste(&self, ctx: &egui::Context) -> Option<String> {
1045        if !ctx.egui_wants_keyboard_input() {
1046            return None;
1047        }
1048        let text = latest_paste(ctx)?;
1049        if !crate::widget::clipboard::is_object_clipboard(&text) {
1050            return None;
1051        }
1052        ctx.input_mut(|i| {
1053            i.events.retain(|e| !matches!(e, egui::Event::Paste(_)));
1054            i.raw.events.retain(|e| !matches!(e, egui::Event::Paste(_)));
1055        });
1056        let focused = ctx
1057            .memory(egui::Memory::focused)
1058            .or_else(|| self.canvas.focused_edit_id());
1059        if let Some(id) = focused {
1060            ctx.memory_mut(|m| m.surrender_focus(id));
1061        }
1062        Some(text)
1063    }
1064
1065    /// The canvas pass: run the active tool over this frame's interaction,
1066    /// draw the level, and publish the tool's cursor.
1067    fn show_canvas(&mut self, ui: &mut egui::Ui) -> Option<Action> {
1068        // The canvas resolves palette colors itself; the grid/background
1069        // chrome is resolved here and handed in (the canvas never sees a
1070        // role). Both go through the *same* palette, so the read-only drain
1071        // (spec §3.2) cannot reach one and miss the other.
1072        let chrome = CanvasChrome {
1073            background: self.session.chrome_color(Role::CanvasBackground),
1074            grid: self.session.chrome_color(Role::GridLine),
1075        };
1076        // The framing a `fit_view` asked for, taken before anything is
1077        // painted: the extent is whatever the draw passes cover, and the
1078        // recorder that measures them draws nothing — so the camera it
1079        // yields is the one this frame paints under. Deferring it to the
1080        // next frame painted the new level once under the old level's
1081        // camera (G2's flash).
1082        let owed = self.session.take_refit();
1083        let session = &mut self.session;
1084        let mut canvas = self.canvas.begin(ui, session.palette(), chrome);
1085        if owed == Refit::Owed {
1086            canvas.fit_to(|painter| session.content_bounds(painter));
1087        }
1088        let framed =
1089            canvas.paint(|interaction, painter| session.canvas_frame(&interaction, painter));
1090        self.selection_screen_bounds = framed.selection_bounds;
1091        // Only apply a tool cursor while the pointer is over the canvas, so it
1092        // never leaks over the nav bar, toolbar, or selection overlay.
1093        let cursor = effective_cursor(
1094            framed.cursor,
1095            if self.canvas.canvas_hovered() {
1096                PointerOver::Canvas
1097            } else {
1098                PointerOver::Elsewhere
1099            },
1100        );
1101        if let Some(cursor) = cursor {
1102            ui.output_mut(|o| {
1103                o.cursor_icon = cursor.egui();
1104            });
1105        }
1106        framed.action
1107    }
1108
1109    /// The chrome that still floats over the canvas: the selection overlay
1110    /// and its pickers, the command palette, and the notices. Everything
1111    /// docked went into [`crate::shell`]. Each layer's request overrides the
1112    /// one below it, starting from what the tool asked for on the canvas.
1113    fn show_canvas_chrome(
1114        &mut self,
1115        ui: &mut egui::Ui,
1116        commands: &mut CommandSet,
1117        open_picker: Option<OpenPicker>,
1118        canvas_action: Option<Action>,
1119    ) -> Option<Action> {
1120        let ctx_owned = ui.ctx().clone();
1121        let ctx = &ctx_owned;
1122        let mut action = canvas_action;
1123        let viewport = self.canvas.viewport();
1124        // §3.6 is a pointer-platform duplicate of the bar, so the gesture is
1125        // read where the canvas is: a right-click over a floating piece of
1126        // chrome belongs to that piece, and a right *drag* pans instead.
1127        let right_click = RightClick::from(
1128            self.canvas.canvas_hovered() && ctx.input(|i| i.pointer.secondary_clicked()),
1129        );
1130        let selection = self
1131            .selection_screen_bounds
1132            .zip(self.session.tool.selection())
1133            .map(|(screen, sel)| Selection {
1134                screen: screen.egui(),
1135                count: sel.count(),
1136            });
1137        let (overlay_action, overlay_corner) = {
1138            let indexed = self
1139                .session
1140                .doc_index
1141                .view(viewed(&self.session.doc, self.session.time_machine.as_ref()).document());
1142            let drawing = Drawing::new(
1143                indexed,
1144                &self.session.path,
1145                &mut self.session.presentation,
1146                &mut self.session.gesture,
1147            );
1148            selection_overlay(
1149                ui,
1150                crate::panels::overlay::Overlay {
1151                    commands,
1152                    data: &drawing,
1153                    theme: &self.session.theme,
1154                    open_picker,
1155                    selection,
1156                    safe: self.safe,
1157                    camera: self.canvas.camera(),
1158                    right_click,
1159                },
1160            )
1161        };
1162        if let Some(overlay_action) = overlay_action {
1163            action = Some(overlay_action);
1164        }
1165        self.overlay_top_right = overlay_corner.map(IntoGeom::geom);
1166        // A popup can't outlive the selection it targets.
1167        if overlay_corner.is_none() {
1168            self.popup = None;
1169        }
1170        // The command palette: Ctrl+K toggles it; a picked row joins the
1171        // action flow like any overlay button.
1172        let palette_toggle = egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, egui::Key::K);
1173        if ctx.input_mut(|i| i.consume_shortcut(&palette_toggle)) {
1174            self.palette = match self.palette {
1175                None => Some(Palette::new()),
1176                Some(_) => None,
1177            };
1178        }
1179        let indexed = self
1180            .session
1181            .doc_index
1182            .view(viewed(&self.session.doc, self.session.time_machine.as_ref()).document());
1183        // Built only for an open palette: the rows are the whole
1184        // document's history, and nothing else in the frame needs them
1185        // read per frame.
1186        let revs = match self.palette {
1187            Some(_) => {
1188                blockworx_store::history::rows(self.session.doc.journal(), self.session.doc.tags())
1189            }
1190            None => Vec::new(),
1191        };
1192        let scope = crate::panels::palette::PaletteScope {
1193            document: &indexed,
1194            path: &self.session.path,
1195            revs: &revs,
1196        };
1197        let outcome = self
1198            .palette
1199            .as_mut()
1200            .map(|palette| palette.show(ctx, commands, scope, viewport.egui()));
1201        match outcome {
1202            None | Some(PaletteOutcome::Open) => {}
1203            Some(PaletteOutcome::Close) => self.palette = None,
1204            Some(PaletteOutcome::Dispatch(palette_action)) => {
1205                action = Some(*palette_action);
1206                self.palette = None;
1207            }
1208        }
1209        // Nothing docked hangs over the canvas, so the notices sit against
1210        // its own top edge.
1211        self.show_document_notices(ui, viewport, Rect::NOTHING);
1212        action
1213    }
1214
1215    /// The top bar: the document menu and its name, the liveness dot, the
1216    /// breadcrumb, the viewing mode, and the right-hand actions (§2.0).
1217    fn show_top_bar(
1218        &mut self,
1219        chrome: &mut crate::shell::Chrome,
1220        commands: &mut CommandSet,
1221    ) -> Option<Action> {
1222        let name = self.document_name();
1223        let names = self.session.scope_names();
1224        let viewing = self.session.viewing();
1225        // The rev's own row, so the bar's age is the history panel's, read
1226        // the same way from the same rows.
1227        let age = match viewing {
1228            blockworx_store::doc::Viewing::Head => None,
1229            blockworx_store::doc::Viewing::Past(at) => {
1230                let rows = blockworx_store::history::rows(
1231                    self.session.doc.journal(),
1232                    self.session.doc.tags(),
1233                );
1234                rows.iter()
1235                    .find(|row| row.rev == at)
1236                    .map(|row| row.since(blockworx_store::history::now()))
1237            }
1238        };
1239        let Consequences { undo, redo } = self.session.consequences();
1240        #[cfg(not(target_arch = "wasm32"))]
1241        let renaming = self.session.doc.renaming();
1242        #[cfg(not(target_arch = "wasm32"))]
1243        let home = self.session.doc.container_root();
1244        let clicked = crate::shell::top_bar::top_bar(
1245            chrome,
1246            commands,
1247            crate::shell::top_bar::TopBar {
1248                name: &name,
1249                scope: crate::shell::top_bar::Scope {
1250                    path: &self.session.path,
1251                    names: &names,
1252                },
1253                steps: crate::shell::top_bar::UndoSteps { undo, redo },
1254                lens: crate::shell::top_bar::Lens {
1255                    viewing,
1256                    head: self.session.doc.repo().rev(),
1257                    age: age.as_deref().unwrap_or_default(),
1258                },
1259                liveness: crate::shell::top_bar::Liveness::of(
1260                    viewing,
1261                    self.session.doc.writability(),
1262                    self.session.doc.projection(),
1263                ),
1264                navigator: self.workspace.open().into(),
1265                theme: &self.session.theme,
1266                prefs: &mut self.preferences,
1267                #[cfg(not(target_arch = "wasm32"))]
1268                recent: self.recent.paths(),
1269                #[cfg(not(target_arch = "wasm32"))]
1270                document: crate::shell::top_bar::Document {
1271                    draft: &mut self.rename_draft,
1272                    renaming,
1273                    home,
1274                },
1275            },
1276        );
1277        if clicked.browse {
1278            self.workspace.toggle();
1279        }
1280        clicked.action
1281    }
1282
1283    /// The status line: §2.0.1's four states, resolved by the module that
1284    /// owns the priority between them.
1285    fn show_status_line(&mut self, chrome: &mut crate::shell::Chrome) {
1286        let cursor = self
1287            .canvas
1288            .canvas_hovered()
1289            .then(|| self.session.hovered_world().map(GridCell::at))
1290            .flatten();
1291        crate::shell::status_line::status_line(
1292            chrome,
1293            crate::shell::status_line::Reading {
1294                tool: crate::tools::names::instruction(crate::tools::names::displayed_tool(
1295                    self.session.tool.name(),
1296                )),
1297                selection: self.selection_path(),
1298                zoom: self.canvas.zoom(),
1299                cursor,
1300                title: self.title_line(),
1301            },
1302        );
1303    }
1304
1305    /// What is selected, as a path — the ephemeral half of §2.0.1, which is
1306    /// why it cannot live in the bar's own breadcrumb.
1307    ///
1308    /// One shape is named where the document names it; several are counted,
1309    /// since a list of names is not a path and would not fit on a line.
1310    fn selection_path(&mut self) -> Option<String> {
1311        let count = self.session.tool.selection().map_or(0, |sel| sel.count());
1312        if count == 0 {
1313            return None;
1314        }
1315        let named = (count == 1)
1316            .then(|| {
1317                let shape = self
1318                    .session
1319                    .tool
1320                    .selection()?
1321                    .shapes()?
1322                    .into_iter()
1323                    .next()?;
1324                let title = self
1325                    .session
1326                    .drawing()
1327                    .shape(shape)?
1328                    .title()?
1329                    .name
1330                    .to_owned();
1331                let mut path = self.session.scope_names();
1332                path.push(title);
1333                Some(path.join(&format!(" {SELECTION_SEPARATOR} ")))
1334            })
1335            .flatten();
1336        Some(named.unwrap_or_else(|| format!("{count} selected")))
1337    }
1338
1339    /// Say what this frame did, where the document moved (§2.0.1's fourth
1340    /// state, R43).
1341    ///
1342    /// Asked of the whole frame rather than of the dispatch, because most
1343    /// edits are a tool's gesture sealing on the canvas rather than an action
1344    /// anybody named. And read off the log rather than off what did it: the
1345    /// label is the one the commit itself carries, so the confirmation and
1346    /// the history row word one edit the same way, and a frame that turned
1347    /// out to write nothing says nothing.
1348    fn confirm_what_landed(&mut self, ctx: &egui::Context, stood: blockworx_doc::rev::Rev) {
1349        let take_back = self.session.doc.trail().next_undo();
1350        let repo = self.session.doc.repo();
1351        let now = repo.rev();
1352        if now == stood {
1353            return;
1354        }
1355        let said = match crate::kernel::session::step_label(&self.session.doc, take_back) {
1356            Some(label) => format!("{label} \u{2014} rev {}", now.get()),
1357            None => format!("Rev {}", now.get()),
1358        };
1359        crate::shell::status_line::say(ctx, said);
1360    }
1361
1362    /// Put the navigator away, because the user is going back to work (§8).
1363    ///
1364    /// More than shutting it: the Hierarchy filter is a transient search, not
1365    /// a persistent view, so it goes too — which supersedes §9's "survives
1366    /// Escape" for this panel, on spec v3's own instruction.
1367    fn dismiss_navigator(&mut self, ctx: &egui::Context) {
1368        if !self.workspace.open() {
1369            return;
1370        }
1371        self.workspace.close();
1372        crate::panels::nav_tree::clear_filter(ctx);
1373    }
1374
1375    /// The navigator's body: whichever view its segments have open.
1376    fn show_navigator_body(&mut self, ui: &mut egui::Ui) -> Option<Action> {
1377        let view = self.workspace.view;
1378        match view {
1379            crate::shell::workspace::PanelView::History => {
1380                let viewing = self.session.viewing();
1381                let Self {
1382                    session,
1383                    history_search,
1384                    ..
1385                } = self;
1386                let (doc, theme) = (&session.doc, &session.theme);
1387                let rows = blockworx_store::history::rows(doc.journal(), doc.tags());
1388                crate::panels::history_panel::body(
1389                    ui,
1390                    crate::panels::history_panel::HistoryPanel {
1391                        scene: crate::panels::history_panel::HistoryScene {
1392                            rows: &rows,
1393                            viewing,
1394                            head: doc.repo().rev(),
1395                            now: blockworx_store::history::now(),
1396                            theme,
1397                        },
1398                        search: history_search,
1399                    },
1400                )
1401            }
1402            crate::shell::workspace::PanelView::Hierarchy => {
1403                let selected_blocks: Vec<BlockId> = self
1404                    .session
1405                    .tool
1406                    .selection()
1407                    .and_then(|d| d.shapes())
1408                    .unwrap_or_default()
1409                    .into_iter()
1410                    .filter_map(ShapeId::block)
1411                    .collect();
1412                let indexed = self
1413                    .session
1414                    .doc_index
1415                    .view(viewed(&self.session.doc, self.session.time_machine.as_ref()).document());
1416                crate::panels::nav_tree::body(
1417                    ui,
1418                    crate::panels::nav_tree::NavScene {
1419                        document: &indexed,
1420                        path: &self.session.path,
1421                        selected: &selected_blocks,
1422                        theme: &self.session.theme,
1423                    },
1424                )
1425            }
1426        }
1427    }
1428
1429    /// What the drawing's title block is stamped with: what the document is
1430    /// called, who is drawing it, which rev is on the canvas — the current
1431    /// one, or the one the time machine is showing — and when that rev was
1432    /// written. The same statement the printed sheet's block carries, since
1433    /// both are drawn from it.
1434    fn title_block(&self) -> crate::tools::title_block::TitleBlock {
1435        let rev = self.session.viewed_repo().rev();
1436        crate::tools::title_block::TitleBlock {
1437            name: self.document_name(),
1438            author: self.session.identity.name.clone(),
1439            rev,
1440            date: self.date_of(rev),
1441            from: self.opened_from(),
1442        }
1443    }
1444
1445    /// When `rev` was written, where this session keeps rows. A scratch
1446    /// session keeps none, and answers `None` rather than the wall clock —
1447    /// which would make two readings of one rev differ.
1448    // The browser has only the scratch arm, which keeps no rows.
1449    #[cfg_attr(target_arch = "wasm32", expect(unused_variables))]
1450    fn written_at(
1451        &self,
1452        rev: blockworx_doc::rev::Rev,
1453    ) -> Option<blockworx_store::record::WallTime> {
1454        match &self.session.doc {
1455            Doc::Scratch { .. } => None,
1456            #[cfg(not(target_arch = "wasm32"))]
1457            Doc::Attached { store, .. } => store.row(rev).map(|row| row.wall_time),
1458        }
1459    }
1460
1461    /// The date `rev` was written — the title block's Date row.
1462    fn date_of(&self, rev: blockworx_doc::rev::Rev) -> Option<String> {
1463        self.written_at(rev).map(blockworx_store::history::date)
1464    }
1465
1466    /// The status line's own title block (R51): who this session attributes
1467    /// its work to, the rev **on the canvas**, and when that rev was written.
1468    /// Under the lens that is the rev being looked at, because a title block
1469    /// describes the drawing in front of the reader.
1470    fn title_line(&self) -> crate::shell::status_line::TitleBlock {
1471        let rev = self.session.viewed_repo().rev();
1472        crate::shell::status_line::TitleBlock {
1473            author: self.session.identity.name.clone(),
1474            rev,
1475            written: self
1476                .written_at(rev)
1477                .map(blockworx_store::history::written_at)
1478                .unwrap_or_default(),
1479        }
1480    }
1481
1482    /// Where this session's document came from, when it was opened from an
1483    /// export (D19). The web build opens no files, so nothing there has a
1484    /// provenance to carry.
1485    // See `document_name`: the web build opens no files.
1486    #[cfg_attr(target_arch = "wasm32", allow(clippy::unused_self))]
1487    fn opened_from(&self) -> Option<blockworx_store::projection::Provenance> {
1488        #[cfg(not(target_arch = "wasm32"))]
1489        {
1490            self.opened_from.clone()
1491        }
1492        #[cfg(target_arch = "wasm32")]
1493        {
1494            None
1495        }
1496    }
1497
1498    /// Why the canvas is read-only, what the next save overwrites, and the
1499    /// loads that did not work — hung below `above`, the chrome band along the
1500    /// top of the canvas, which they may never cover. Drawn only when there is
1501    /// something to say, so an ordinary session says nothing at all.
1502    ///
1503    /// A past rev on the canvas is *not* said here: it is stamped across
1504    /// the drawing itself, and every way out of it is in the history
1505    /// cluster. What is left is the container's own facts.
1506    fn show_document_notices(&mut self, ui: &mut egui::Ui, viewport: Rect, above: Rect) {
1507        let notices = self.notices();
1508        if let Some(crate::panels::notices::Acknowledged(failure)) =
1509            crate::panels::notices::draw(ui, viewport.egui(), above.egui(), &notices)
1510        {
1511            self.failures.remove(failure);
1512        }
1513    }
1514
1515    /// What this session has to tell the user: its failures first, since a
1516    /// failure is news, then the standing facts about its files.
1517    fn notices(&self) -> Vec<crate::panels::notices::Notice> {
1518        use crate::panels::notices::Notice;
1519        self.failures
1520            .iter()
1521            .map(|failure| Notice::Failure(failure.clone()))
1522            .chain(self.file_notices().into_iter().map(Notice::Standing))
1523            .collect()
1524    }
1525
1526    /// The standing facts about the files under this document. The browser has
1527    /// none, so it has nothing to say.
1528    #[cfg(not(target_arch = "wasm32"))]
1529    fn file_notices(&self) -> Vec<String> {
1530        use blockworx_store::projection::Freshness;
1531        let mut notices = Vec::new();
1532        if let Some(reason) = self.session.doc.read_only_reason() {
1533            notices.push(format!("Read-only \u{2014} {reason}"));
1534        }
1535        if self.session.doc.projection() == Some(Freshness::Unrecognized) {
1536            notices.push(format!(
1537                "{} was not written by a fold of this log \u{2014} the next save overwrites it",
1538                blockworx_store::container::PROJECTION,
1539            ));
1540        }
1541        notices
1542    }
1543
1544    #[cfg(target_arch = "wasm32")]
1545    #[expect(clippy::unused_self, reason = "the native arm reads the container")]
1546    fn file_notices(&self) -> Vec<String> {
1547        Vec::new()
1548    }
1549
1550    /// Say on the canvas — and on the console — that something did not work.
1551    /// It stands until the user acknowledges it, or until the document it was
1552    /// about is replaced.
1553    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
1554    fn report_failure(&mut self, failure: String) {
1555        tracing::error!("{failure}");
1556        self.failures.push(failure);
1557    }
1558
1559    /// Keyboard copy/paste/undo/nudge, independent of the mouse-driven actions.
1560    /// Skipped while a rename `TextEdit` holds focus so Cmd+C/V edits its text
1561    /// instead of the canvas selection (the canvas itself runs unfocused).
1562    fn handle_keyboard(&self, ctx: &egui::Context) -> Option<Action> {
1563        use egui::{Key, KeyboardShortcut, Modifiers};
1564        if ctx.egui_wants_keyboard_input() {
1565            return None;
1566        }
1567        let redo_z = KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
1568        let redo_y = KeyboardShortcut::new(Modifiers::COMMAND, Key::Y);
1569        let undo_z = KeyboardShortcut::new(Modifiers::COMMAND, Key::Z);
1570        let paste_txt = latest_paste(ctx);
1571        // Copy is the one chord a read-only session keeps: it is how the
1572        // time machine pays off (copy out of the past, paste into the
1573        // present). The chords are consumed either way, so a withheld one
1574        // does nothing rather than falling through to another handler.
1575        let writable = self.session.may_write() == blockworx_store::doc::Writability::Writable;
1576        if ctx.input_mut(|i| i.consume_shortcut(&redo_z) || i.consume_shortcut(&redo_y)) {
1577            // Not gated on writability: the stack holds §7.1's two kinds, and
1578            // taking back a camera move costs the log nothing — so the chord
1579            // dispatches and `step_history` refuses only what would author.
1580            Some(Action::Redo)
1581        } else if ctx.input_mut(|i| i.consume_shortcut(&undo_z)) {
1582            Some(Action::Undo)
1583        } else if let Some(text) = paste_txt {
1584            writable.then_some(Action::Paste(text))
1585        } else if ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Copy))) {
1586            match self.session.tool.selection() {
1587                Some(Deletable::Pins(pins)) => Some(Action::CopyPins(pins)),
1588                other => other.and_then(|d| d.shapes()).map(Action::Copy),
1589            }
1590        } else if writable && self.session.tool.selection().is_some() {
1591            arrow_nudge(ctx)
1592        } else {
1593            None
1594        }
1595    }
1596
1597    /// Apply one action: the single place a frame's requests — from the tool,
1598    /// the chrome, or the keyboard — take effect on the document and app state.
1599    fn dispatch_action(&mut self, ctx: &egui::Context, action: Action) {
1600        // The session answers everything that is about the document or where
1601        // the editor is standing; what comes back is what only a surface can
1602        // do — a dialog, an export thread, a picker window, a camera the view
1603        // eases into place.
1604        let was = self.session.viewing();
1605        let unhandled = self.session.dispatch(action);
1606        if self.session.viewing() != was {
1607            // `settle_on_viewed` dropped the selection the popup hangs off.
1608            self.popup = None;
1609        }
1610        if let Some(json) = self.session.take_clipboard() {
1611            ctx.copy_text(json);
1612        }
1613        self.apply_framings(ctx);
1614        let Some(action) = unhandled else {
1615            return;
1616        };
1617        match action {
1618            Action::OpenRolePicker { target } => self.open_popup(ctx, Popup::Role(target)),
1619            Action::OpenPinTypePicker { pins } => self.open_popup(ctx, Popup::PinType(pins)),
1620            Action::Camera(rect) => self.canvas.fit_to_rect_instant(rect),
1621            Action::Export { format, selection } => self.export(ctx, format, selection),
1622            Action::ExportRev { at, to } => self.export_rev(ctx, at, to),
1623            Action::Import => {
1624                self.pending_import = Some(crate::import::spawn_import_dialog(ctx));
1625            }
1626            Action::PickImage(target) => {
1627                self.pending_image = Some((target, crate::import::spawn_image_dialog(ctx)));
1628            }
1629            #[cfg(not(target_arch = "wasm32"))]
1630            Action::NewDocument => self.new_document(),
1631            #[cfg(not(target_arch = "wasm32"))]
1632            Action::RenameDocument(name) => self.rename_document(&name),
1633            #[cfg(not(target_arch = "wasm32"))]
1634            Action::PickFile(request) => {
1635                self.pending_file = Some(crate::file::spawn_file_dialog(ctx, request));
1636            }
1637            #[cfg(not(target_arch = "wasm32"))]
1638            Action::OpenRecent(root) => self.open_container(&root),
1639            // The browser has no container to project into, and
1640            // `Saving::Withheld` there keeps the command out of the registry
1641            // — so this arm is reachable only natively.
1642            Action::SaveProjection => {
1643                #[cfg(not(target_arch = "wasm32"))]
1644                self.save_projection();
1645            }
1646            other => unreachable!("the session answered {}", other.label()),
1647        }
1648    }
1649
1650    /// Where the session sees the camera standing, this frame: the view's own
1651    /// vantage, the viewport it was laid out in, the region the chrome left
1652    /// clear, and whether the user worked it themselves.
1653    fn sighting(&self, ctx: &egui::Context) -> Sighting {
1654        Sighting {
1655            vantage: self.canvas.vantage(),
1656            viewport: self.canvas.viewport(),
1657            safe: self.safe.region().geom(),
1658            camera: if self.canvas.worked_camera() {
1659                CameraWork::Worked
1660            } else {
1661                CameraWork::Idle
1662            },
1663            pointer: ctx.pointer_hover_pos().map(IntoGeom::geom),
1664        }
1665    }
1666
1667    /// Push where the camera stands into the session, so what it reads back
1668    /// this frame is where the view actually is.
1669    fn sync_camera(&mut self, ctx: &egui::Context) {
1670        let sighting = self.sighting(ctx);
1671        self.session.sees(sighting);
1672    }
1673
1674    /// Apply the moves the session asked of the camera, and tell it where
1675    /// that left the view.
1676    fn apply_framings(&mut self, ctx: &egui::Context) {
1677        for framing in self.session.take_framings() {
1678            match framing {
1679                Framing::StandAt(vantage) => self.canvas.stand_at(vantage),
1680                Framing::Fit => self.session.fit_view(),
1681                Framing::BringIntoView(world, glide) => {
1682                    self.canvas.bring_into_view(world, glided(glide));
1683                }
1684                Framing::FocusOn(world, glide) => self.canvas.focus_on(world, glided(glide)),
1685                Framing::Zoom(step, anchor) => self.canvas.zoom_step(step, anchor),
1686            }
1687        }
1688        self.sync_camera(ctx);
1689    }
1690
1691    /// Open a selection popup, replacing whatever was open. It renders from the
1692    /// top of the next frame so this opening click isn't read as a
1693    /// click-outside dismiss; the selection stays in the tool behind it.
1694    fn open_popup(&mut self, ctx: &egui::Context, popup: Popup) {
1695        self.popup = Some(popup);
1696        ctx.request_repaint();
1697    }
1698
1699    /// Take `doc` as the session's document and settle everything that
1700    /// pointed at the last one. The handle it displaces drops here, which is
1701    /// what gives a container's lock back — there is nothing to save on the
1702    /// way out, since every commit was durable when it was made.
1703    #[cfg(not(target_arch = "wasm32"))]
1704    fn take_document(&mut self, doc: Doc) {
1705        let was = self.workspace_key();
1706        self.session.adopt(doc);
1707        // The store that just dropped gave a born container its lock back,
1708        // so one this session made and left behind can go now rather than
1709        // waiting for the exit.
1710        self.sweep_unclaimed();
1711        self.session.path = BlockPath::opening(self.session.doc.document());
1712        self.session.presentation = crate::presentation::Presentation::default();
1713        self.session.gesture = crate::gesture::Gesture::idle();
1714        // The steps on the old stack name revs of a trail this document
1715        // does not have; the new one's trail is what it stands ready to
1716        // invert, which for a reopened container is everything it was
1717        // closed with (F6).
1718        self.session.undo_stack = crate::history::UndoStack::reconstructed(
1719            self.session.doc.trail(),
1720            &self.session.state(),
1721        );
1722        self.after_document_swap(&was);
1723    }
1724
1725    /// File ▸ New: a document born attached, under a name of three words
1726    /// (D20). A container that cannot be created leaves the session on a
1727    /// scratch document — the editor opens either way — and says why.
1728    #[cfg(not(target_arch = "wasm32"))]
1729    fn new_document(&mut self) {
1730        self.opened = None;
1731        self.opened_from = None;
1732        match self.documents.create(blockworx_store::naming::entropy) {
1733            Ok(store) => {
1734                self.unclaimed.push(store.root().to_path_buf());
1735                self.take_document(Doc::attached(store));
1736            }
1737            Err(failure) => {
1738                // After the swap: adopting a document clears the notices
1739                // the last one collected, this one among them.
1740                self.take_document(Doc::default());
1741                self.report_failure(failure.notice());
1742            }
1743        }
1744    }
1745
1746    /// Call this document something else, which renames its container (D20).
1747    /// The session carries straight on into it: the log it is appending to
1748    /// is held open, so the next edit lands in the renamed container.
1749    #[cfg(not(target_arch = "wasm32"))]
1750    fn rename_document(&mut self, name: &str) {
1751        let Some(root) = self.attached_root() else {
1752            self.report_failure("This session has no diagram on disk to rename".to_owned());
1753            return;
1754        };
1755        let Some(to) = crate::file::renamed_beside(&root, name) else {
1756            self.report_failure(format!("\u{201c}{name}\u{201d} is not a diagram name"));
1757            return;
1758        };
1759        if let Err(refusal) = self.session.doc.rename(&to) {
1760            self.report_failure(format!(
1761                "Failed to rename {}: {refusal}",
1762                crate::file::document_name(&root),
1763            ));
1764            return;
1765        }
1766        self.recent.forget(&root);
1767        // The panel state follows the document to its new name.
1768        self.settle_workspace(&crate::file::document_name(&root));
1769        // A name of the user's own is a claim on the document, so a
1770        // renamed container is never swept and joins the recent list.
1771        self.claim(&to);
1772    }
1773
1774    /// A container this session created stops being unclaimed the moment
1775    /// the user commits to it — the first edit, or a name of their own —
1776    /// and joins the recent list then, so the list never fills with names
1777    /// nobody kept.
1778    #[cfg(not(target_arch = "wasm32"))]
1779    fn claim(&mut self, root: &Path) {
1780        self.unclaimed.retain(|born| born != root);
1781        self.recent.remember(root);
1782    }
1783
1784    /// The first record in a born container's log is what claims it. Called
1785    /// from the write door, so every path that can write the first one —
1786    /// an edit, a paste, an import, a restore — goes through here.
1787    #[cfg(not(target_arch = "wasm32"))]
1788    fn claim_if_written(&mut self) {
1789        if self.unclaimed.is_empty() || self.session.doc.repo().log().is_empty() {
1790            return;
1791        }
1792        if let Some(root) = self.attached_root()
1793            && self.unclaimed.contains(&root)
1794        {
1795            self.claim(&root);
1796        }
1797    }
1798
1799    /// D20's pristine cleanup, narrowed to what this session made: a
1800    /// container the user opened is theirs however empty it is, and
1801    /// deleting it would not be ours to decide. The one still open is left
1802    /// for the sweep on the way out, which runs after its store — and its
1803    /// lock — have been dropped.
1804    #[cfg(not(target_arch = "wasm32"))]
1805    fn sweep_unclaimed(&mut self) {
1806        use blockworx_store::container::{Discarded, discard_pristine};
1807        let open = self.attached_root();
1808        let mut still_open = Vec::new();
1809        for root in std::mem::take(&mut self.unclaimed) {
1810            if Some(&root) == open.as_ref() {
1811                still_open.push(root);
1812                continue;
1813            }
1814            match discard_pristine(&root) {
1815                Ok(Discarded::Removed) => {
1816                    tracing::info!("removed {}, which held no edit", root.display());
1817                }
1818                Ok(Discarded::Kept) => {}
1819                Err(e) => tracing::warn!("{} could not be tidied away: {e}", root.display()),
1820            }
1821        }
1822        self.unclaimed = still_open;
1823    }
1824
1825    /// Open the container at `root`, or say why not. A held lock, a broken
1826    /// log and a failed write are outcomes rather than failures: the
1827    /// container opens read-only and the chrome carries the reason. Only a
1828    /// path that is no container — or has become one no longer — costs it
1829    /// its place in the recent list.
1830    #[cfg(not(target_arch = "wasm32"))]
1831    fn open_container(&mut self, root: &Path) {
1832        // The folder picker takes any folder, so the choice is judged here
1833        // rather than by the store's own "it has no log" complaint, which
1834        // answers a question the user did not ask.
1835        if let Some(refusal) = crate::file::refused_as_a_diagram(root) {
1836            self.report_failure(refusal);
1837            self.recent.forget(root);
1838            return;
1839        }
1840        match crate::file::open_container(root) {
1841            Ok(store) => {
1842                if let Some(reason) = store.read_only_reason() {
1843                    tracing::warn!("{} opened read-only: {reason}", root.display());
1844                }
1845                self.recent.remember(root);
1846                self.opened = None;
1847                self.take_document(Doc::attached(store));
1848            }
1849            Err(refusal) => {
1850                self.report_failure(format!("Failed to open {}: {refusal}", root.display()));
1851                self.recent.forget(root);
1852            }
1853        }
1854    }
1855
1856    /// Open the shared diagram at `bundle`, which is always unpack-then-open:
1857    /// the working form is a directory, because a commit is an append and an
1858    /// fsync and the lock is a file another process can see (R54).
1859    ///
1860    /// It unpacks beside the zip, under the name the zip travelled under —
1861    /// where a file that arrived by e-mail is wanted, in the directory it was
1862    /// opened from. Where that name is taken, the destination becomes a
1863    /// question rather than a casualty: a second dialog, and nothing written
1864    /// until it is answered. The zip itself is never touched.
1865    #[cfg(not(target_arch = "wasm32"))]
1866    fn open_bundle(&mut self, ctx: &egui::Context, bundle: &Path) {
1867        match crate::file::unpacks_to(bundle) {
1868            crate::file::Landing::Beside(root) => self.unpack_and_open(bundle, &root),
1869            crate::file::Landing::Occupied(_) => {
1870                self.pending_file = Some(crate::file::spawn_file_dialog(
1871                    ctx,
1872                    crate::file::FileRequest::UnpackBundle(bundle.to_path_buf()),
1873                ));
1874            }
1875        }
1876    }
1877
1878    /// Lay `bundle` out at `root` and open what came out of it, which is an
1879    /// ordinary container from there on — chain, stamp and replay are checked
1880    /// by the open it goes through, not by the unpack.
1881    #[cfg(not(target_arch = "wasm32"))]
1882    fn unpack_and_open(&mut self, bundle: &Path, root: &Path) {
1883        match crate::file::unpack_bundle(bundle, root) {
1884            Ok(()) => self.open_container(root),
1885            // Item 16's failure path, with the same shape its refusals have:
1886            // what was picked, and what was wrong with it.
1887            Err(why) => self.report_failure(format!(
1888                "{} is not a shared diagram: {why}",
1889                crate::file::container_name(bundle),
1890            )),
1891        }
1892    }
1893
1894    /// Write the open container into one file at `to` — the transfer form
1895    /// (R54), whole history and no lock.
1896    #[cfg(not(target_arch = "wasm32"))]
1897    fn share_bundle(&mut self, ctx: &egui::Context, to: &Path) {
1898        let Some(root) = self.attached_root() else {
1899            // Unreachable through the menu, whose entry is disabled without
1900            // a container, and cheaper to answer than to prove.
1901            return self.report_failure("this session has no diagram on disk to share".to_owned());
1902        };
1903        match crate::file::share_container(&root, to) {
1904            // Routine, so the status line says it and the toast stays rare
1905            // (R43).
1906            Ok(()) => crate::shell::status_line::say(
1907                ctx,
1908                format!("Shared as {}", crate::file::container_name(to)),
1909            ),
1910            Err(why) => {
1911                tracing::error!("Failed to share {}: {why}", to.display());
1912                crate::shell::toast::say(
1913                    ctx,
1914                    format!(
1915                        "Could not share {}: {why}",
1916                        crate::file::document_name(&root),
1917                    ),
1918                );
1919            }
1920        }
1921    }
1922
1923    /// Write this session's document into a container at `root` — the one
1924    /// save left, since an attached document is written as it is edited.
1925    ///
1926    /// Two paths, because a session with a log and a session without one are
1927    /// different problems. A container's log *is* its document, so `scope`'s
1928    /// rev is a cut in its lines and the save copies them: everything the
1929    /// records carry — wall times, authors, edit/undo/redo kinds, tags —
1930    /// comes over because nothing is rewritten (R37). A scratch session has
1931    /// only its commits, so those are seeded, and a cut is a prefix of them.
1932    #[cfg(not(target_arch = "wasm32"))]
1933    fn save_as_container(
1934        &mut self,
1935        ctx: &egui::Context,
1936        root: &Path,
1937        scope: crate::file::SaveScope,
1938    ) {
1939        use crate::file::SaveScope;
1940        let written = if let Some(source) = self.session.doc.container_root().map(Path::to_path_buf)
1941        {
1942            let at = match scope {
1943                SaveScope::Through(at) => at,
1944                SaveScope::Whole => self.session.doc.repo().rev(),
1945            };
1946            crate::file::save_container_through(&source, at, root, &self.session.identity)
1947                .map_err(|refusal| refusal.to_string())
1948        } else {
1949            let log = self.session.doc.repo().log();
1950            let through = match scope {
1951                SaveScope::Through(at) => at.get() as usize,
1952                SaveScope::Whole => log.len(),
1953            };
1954            let Some(prefix) = log.get(..through) else {
1955                // Unreachable through the menu, which reads the rev off the
1956                // canvas — and cheaper to answer than to prove.
1957                return self.report_failure(format!("this session has no rev {through}"));
1958            };
1959            crate::file::create_container(root, prefix, &self.session.identity)
1960                .map_err(|refusal| refusal.to_string())
1961        };
1962        match written {
1963            Ok(store) => {
1964                self.recent.remember(root);
1965                self.opened = None;
1966                self.session.adopt(Doc::attached(store));
1967                // The document is unchanged — only its home is — so the view
1968                // and the selection stay put. The undo stack cannot: what
1969                // this session may take back is whatever the new container's
1970                // own trail holds, which for a seeded one is nothing (the
1971                // commits are its past, not steps anybody took here) and for
1972                // a copied log is what replaying it gave back.
1973                self.session.undo_stack = crate::history::UndoStack::reconstructed(
1974                    self.session.doc.trail(),
1975                    &self.session.state(),
1976                );
1977                // A container the user just named should be readable from
1978                // the moment it exists, not from the first explicit save.
1979                self.save_projection();
1980                // What was on the canvas is now the head of a document this
1981                // session may write — which is the whole point of saving
1982                // from inside the lens, so the lens closes on it.
1983                self.session.view_head();
1984                // A save that worked is routine, so it is said in the
1985                // status line rather than raised as an attention event
1986                // (R43); the failure below still is one.
1987                crate::shell::status_line::say(ctx, saved_as(root, scope));
1988            }
1989            Err(refusal) => {
1990                // Toasted rather than pinned as a notice: nothing about the
1991                // session changed, so there is no standing fact to
1992                // acknowledge — only news, which is what a toast is for.
1993                tracing::error!("Failed to write {}: {refusal}", root.display());
1994                crate::shell::toast::say(
1995                    ctx,
1996                    format!(
1997                        "Could not save as {}: {refusal}",
1998                        crate::file::document_name(root),
1999                    ),
2000                );
2001            }
2002        }
2003    }
2004
2005    /// Refresh `document.json` beside the log. The commits are already
2006    /// durable, so this cannot lose work; what it can do is overwrite a
2007    /// projection nobody recognizes, which is said out loud first.
2008    #[cfg(not(target_arch = "wasm32"))]
2009    fn save_projection(&mut self) {
2010        use blockworx_store::projection::Freshness;
2011        if self.session.doc.projection() == Some(Freshness::Unrecognized) {
2012            tracing::warn!(
2013                "overwriting a {} that no fold of this log wrote — a hand-edited \
2014                 projection comes back in through Import, never through a save",
2015                blockworx_store::container::PROJECTION,
2016            );
2017        }
2018        if let Err(refusal) = self.session.doc.save_projection() {
2019            self.report_failure(format!("Failed to write the diagram file: {refusal}"));
2020        }
2021    }
2022
2023    /// Keep `document.json` fresh without a dirty state: a stale projection
2024    /// on a writable container is rewritten once the head has sat still for
2025    /// [`Self::PROJECTION_SETTLE`] — which also covers a projection a crash
2026    /// left behind, shortly after open. An Unrecognized (hand-edited) file
2027    /// stays guarded behind an explicit Save.
2028    #[cfg(not(target_arch = "wasm32"))]
2029    fn refresh_projection(&mut self, ctx: &egui::Context) {
2030        self.refresh_projection_at(ctx, std::time::Instant::now());
2031    }
2032
2033    #[cfg(not(target_arch = "wasm32"))]
2034    fn refresh_projection_at(&mut self, ctx: &egui::Context, now: std::time::Instant) {
2035        use blockworx_store::projection::Freshness;
2036        if self.session.doc.projection() != Some(Freshness::Stale)
2037            || self.session.doc.read_only_reason().is_some()
2038        {
2039            self.head_moved = None;
2040            return;
2041        }
2042        let head = self.session.doc.repo().rev();
2043        let since = match self.head_moved {
2044            Some((seen, at)) if seen == head => at,
2045            _ => now,
2046        };
2047        self.head_moved = Some((head, since));
2048        let waited = now.duration_since(since);
2049        match Self::PROJECTION_SETTLE.checked_sub(waited) {
2050            None | Some(core::time::Duration::ZERO) => {
2051                self.save_projection();
2052                self.head_moved = None;
2053            }
2054            Some(remaining) => ctx.request_repaint_after(remaining),
2055        }
2056    }
2057
2058    /// How long the head sits still before the projection follows it.
2059    #[cfg(not(target_arch = "wasm32"))]
2060    const PROJECTION_SETTLE: core::time::Duration = core::time::Duration::from_secs(2);
2061
2062    /// Deliver a File-menu pick once its off-thread dialog resolves.
2063    #[cfg(not(target_arch = "wasm32"))]
2064    fn poll_pending_file(&mut self, ctx: &egui::Context) {
2065        let Some(rx) = &self.pending_file else {
2066            return;
2067        };
2068        match rx.try_recv() {
2069            Ok(Some(pick)) => {
2070                self.pending_file = None;
2071                match pick {
2072                    crate::file::FilePick::Container(root) => self.open_container(&root),
2073                    crate::file::FilePick::Bundle(bundle) => self.open_bundle(ctx, &bundle),
2074                    crate::file::FilePick::UnpackedInto(bundle, root) => {
2075                        self.unpack_and_open(&bundle, &root);
2076                    }
2077                    crate::file::FilePick::NewContainer(root, scope) => {
2078                        self.save_as_container(ctx, &root, scope);
2079                    }
2080                    crate::file::FilePick::NewBundle(to) => self.share_bundle(ctx, &to),
2081                }
2082            }
2083            // A cancelled dialog and a dropped sender both end the pick.
2084            Ok(None) | Err(std::sync::mpsc::TryRecvError::Disconnected) => {
2085                self.pending_file = None;
2086            }
2087            // See `poll_pending_import`: a native picker can sit open for
2088            // minutes, so poll it gently.
2089            Err(std::sync::mpsc::TryRecvError::Empty) => {
2090                ctx.request_repaint_after(std::time::Duration::from_millis(100));
2091            }
2092        }
2093    }
2094
2095    /// Deliver a picked import file once the off-thread dialog resolves.
2096    fn poll_pending_import(&mut self, ctx: &egui::Context) {
2097        let Some(rx) = &self.pending_import else {
2098            return;
2099        };
2100        match rx.try_recv() {
2101            Ok(Some((name, bytes))) => {
2102                self.pending_import = None;
2103                self.session.handle_imported(&name, bytes);
2104            }
2105            // A cancelled dialog and a dropped sender both end the import.
2106            Ok(None) | Err(std::sync::mpsc::TryRecvError::Disconnected) => {
2107                self.pending_import = None;
2108            }
2109            // Poll the open dialog at a gentle cadence, not per-frame —
2110            // a native picker can sit open for minutes.
2111            Err(std::sync::mpsc::TryRecvError::Empty) => {
2112                ctx.request_repaint_after(std::time::Duration::from_millis(100));
2113            }
2114        }
2115    }
2116
2117    /// Deliver a picked image once the off-thread dialog resolves — to the
2118    /// same target the request carried, so a pick that took minutes still
2119    /// lands where the gesture that asked for it meant it to.
2120    fn poll_pending_image(&mut self, ctx: &egui::Context) {
2121        let Some((_, rx)) = &self.pending_image else {
2122            return;
2123        };
2124        match rx.try_recv() {
2125            Ok(asset) => {
2126                if let Some((target, _)) = self.pending_image.take() {
2127                    self.dispatch_action(ctx, Action::ImagePicked { target, asset });
2128                }
2129            }
2130            // A dropped sender ends the pick with nothing to place.
2131            Err(std::sync::mpsc::TryRecvError::Disconnected) => {
2132                self.pending_image = None;
2133            }
2134            // See `poll_pending_import`: a native picker can sit open for
2135            // minutes, so poll it gently.
2136            Err(std::sync::mpsc::TryRecvError::Empty) => {
2137                ctx.request_repaint_after(std::time::Duration::from_millis(100));
2138            }
2139        }
2140    }
2141
2142    /// What a cell carried off the tool cluster and released comes to: a
2143    /// stamp at the drop point, in the canvas's own coordinates.
2144    ///
2145    /// `None` where the drop landed on the glass, on the navigator, or off the
2146    /// window — the gesture ends and the cell springs back, because a
2147    /// release the canvas never saw must not become a thing on it.
2148    fn dropped(
2149        &self,
2150        ctx: &egui::Context,
2151        carried: crate::shell::tool_cluster::DragOut,
2152    ) -> Option<Action> {
2153        let onto_the_canvas = self.canvas.viewport().contains(carried.at.geom())
2154            && !crate::shell::over_the_chrome(ctx, carried.at);
2155        onto_the_canvas.then(|| Action::StampTool {
2156            tool: carried.tool,
2157            at: self.canvas.screen_to_world_pos(carried.at.geom()),
2158        })
2159    }
2160
2161    /// One frame of the whole editor: the floating chrome of
2162    /// `docs/cad-ui-spec.md` §2, then the canvas edge to edge underneath it.
2163    ///
2164    /// The chrome is drawn *before* the canvas so its boxes are measured by
2165    /// the time a framing needs them — nothing is docked, so fit-to-view has
2166    /// only the safe area to keep the drawing off the glass (§2.1). The z
2167    /// order is the layers': every piece is an [`egui::Area`], which paints
2168    /// and hit-tests above the canvas's background layer whatever order the
2169    /// code draws them in.
2170    ///
2171    /// Split out of [`eframe::App::ui`] so a test can drive a real frame
2172    /// without an `eframe::Frame` to hand.
2173    pub(crate) fn shell_frame(&mut self, ui: &mut egui::Ui) {
2174        let _frame_span = tracing::info_span!("frame").entered();
2175        // The phases below drive popups and keyboard handling through the
2176        // `Context`, so bind it once. The clone is a cheap `Arc` bump and keeps
2177        // `ctx` a `&Context` without holding a borrow on `ui`, which the canvas
2178        // still needs mutably.
2179        let ctx_owned = ui.ctx().clone();
2180        let ctx = &ctx_owned;
2181        // Apply appearance preferences (theme/mode/font/visuals/zoom) before
2182        // drawing, so this frame renders with the current look.
2183        self.apply_preferences(ctx);
2184        // Where the camera stands, as of this frame — and any move the session
2185        // asked for outside one. The session reads the vantage back for the
2186        // undo stack's entry and for the world the pointer is in; it asks for
2187        // moves through here rather than holding a camera of its own.
2188        self.apply_framings(ctx);
2189        #[cfg(not(target_arch = "wasm32"))]
2190        self.refresh_projection(ctx);
2191        #[cfg(not(target_arch = "wasm32"))]
2192        self.apply_window_title(ctx);
2193        // Popups first: they read the overlay corner the chrome captured last
2194        // frame, and the picker they report is what the chrome shows as active.
2195        let (open_picker, picked) = self.show_popups(ctx);
2196        self.show_editor_windows(ctx);
2197        if !self.images_loaded {
2198            self.canvas.register_icons(ctx);
2199            self.images_loaded = true;
2200        }
2201        // Before the canvas: the interception has to reach the Paste event
2202        // ahead of the focused editor, and runs outside the canvas's borrow of
2203        // the document.
2204        let object_paste = self.intercept_object_paste(ctx);
2205        // The undo stack's frame: everything from here to the end of dispatch
2206        // is one step, whether it edits the document, moves the view, or both.
2207        let view_before = self.session.state();
2208        // Where the log stood before any of it, so the status line can say what
2209        // landed however it landed — a dispatched action, or a tool's own
2210        // gesture sealing on the canvas.
2211        let stood = self.session.doc.repo().rev();
2212
2213        // One registry for the whole frame, so no two bands can disagree about
2214        // what is available.
2215        let current_lock: InterfaceLock = self.session.drawing().current_locked().into();
2216        let mut commands = self.session.available_commands(current_lock);
2217        // Tool chords dispatch through the registry like any button; gated
2218        // off while a text field owns the keyboard.
2219        let mut chrome_action = if !ctx.egui_wants_keyboard_input()
2220            && let Some(id) = crate::keys::consume_binding(ctx)
2221        {
2222            commands.take(id)
2223        } else {
2224            None
2225        };
2226        let mut band = |action: Option<Action>| {
2227            if action.is_some() {
2228                chrome_action = action;
2229            }
2230        };
2231
2232        // Nothing reflows the canvas, so it is the whole of what this `Ui`
2233        // holds and the chrome stands over it.
2234        //
2235        // Escape is claimed in the spec's own order: the navigator closes
2236        // before anything else reads the key (§8), and only then does the
2237        // lens hear it. Both are claimed before the canvas pass, so a tool
2238        // armed before either opened cannot answer the key first.
2239        if crate::shell::navigator::escape_closes(ctx, self.workspace.open().into()) {
2240            self.dismiss_navigator(ctx);
2241        } else if let blockworx_store::doc::Viewing::Past(_) = self.session.viewing() {
2242            band(crate::shell::top_bar::escape_exits(ctx));
2243        }
2244        let mut chrome = crate::shell::Chrome::over(ctx, ui.max_rect());
2245        band(self.show_top_bar(&mut chrome, &mut commands));
2246        {
2247            let frame = crate::shell::tool_cluster::tool_cluster(
2248                &mut chrome,
2249                &mut commands,
2250                crate::shell::tool_cluster::ToolCluster {
2251                    selected: crate::tools::names::displayed_tool(self.session.tool.name()),
2252                    viewing: self.session.viewing(),
2253                },
2254            );
2255            // §8: a tool pick is unambiguous intent to edit, and editing is
2256            // not possible while the navigator is up, so it closes.
2257            if frame.action.is_some() || frame.drag_out.is_some() {
2258                self.dismiss_navigator(ctx);
2259            }
2260            band(frame.action);
2261            if let Some(carried) = frame.drag_out {
2262                band(self.dropped(ctx, carried));
2263            }
2264        }
2265        {
2266            // Drawn every frame, not only while it is open: it slides in and
2267            // out, so the frames after it is put away still hold a panel.
2268            //
2269            // The navigator edits the state its segments own; the body needs
2270            // the rest of the app, so the two cannot borrow `self` at once.
2271            // The state is `Copy`, so it goes in and comes back out.
2272            let mut state = self.workspace;
2273            let drawn = crate::shell::navigator::navigator(&mut chrome, &mut state, |ui| {
2274                self.show_navigator_body(ui)
2275            });
2276            self.workspace = state;
2277            if drawn.dismissed {
2278                // The press is *not* spent (§8): a canvas click dismisses
2279                // and performs its selection in the one gesture, so it goes
2280                // on to the canvas underneath.
2281                self.dismiss_navigator(ctx);
2282            }
2283            band(drawn.action);
2284        }
2285        self.show_status_line(&mut chrome);
2286        // The toast is not a piece of the frame: it says what needs
2287        // attention, cannot be pressed, and takes no room — so it is drawn
2288        // beside the chrome rather than measured with it (R38, R43).
2289        crate::shell::toast::toast(ctx, &self.session.theme);
2290        // Measured, never cached: the navigator opening or closing moves this
2291        // in the frame it happens, and the framing below reads it fresh
2292        // (§2.1).
2293        self.safe = chrome.safe();
2294        self.canvas.set_safe_region(self.safe.region().geom());
2295        // The canvas, edge to edge under the glass.
2296        egui::CentralPanel::no_frame().show(ui, |ui| {
2297            let frame = self.show_canvas(ui);
2298            band(self.show_canvas_chrome(ui, &mut commands, open_picker, frame));
2299        });
2300        // The canvas pass is where the pan, the wheel and any owed fit reach
2301        // the camera, so the entry the stack takes below is measured from
2302        // where they left it.
2303        self.sync_camera(ctx);
2304
2305        // An intercepted object paste wins over whatever the frame asked for,
2306        // so the object lands even when an editor was focused; the keyboard
2307        // only speaks when nothing else did.
2308        // A pick outranks the chrome and the keyboard: the popup is in front,
2309        // so a click that reached it was meant for it.
2310        let action = match object_paste {
2311            Some(text) => Some(Action::Paste(text)),
2312            None => picked
2313                .or(chrome_action)
2314                .or_else(|| self.handle_keyboard(ctx)),
2315        };
2316        if let Some(action) = action {
2317            self.dispatch_action(ctx, action);
2318        }
2319        // A tool's own frame can ask for one too (a paste brought into view).
2320        self.apply_framings(ctx);
2321        if let Some(after) = self.session.take_repaint() {
2322            ctx.request_repaint_after(after);
2323        }
2324        self.confirm_what_landed(ctx, stood);
2325        self.session.record_history(&view_before, now(ctx));
2326        self.poll_pending_import(ctx);
2327        self.poll_pending_image(ctx);
2328        #[cfg(not(target_arch = "wasm32"))]
2329        self.poll_pending_file(ctx);
2330        // The first record in a born container's log is what claims it, and
2331        // every door that can write it — an edit, a paste, an import, a
2332        // restore — has run by now.
2333        #[cfg(not(target_arch = "wasm32"))]
2334        self.claim_if_written();
2335    }
2336
2337    /// Which document the workspace's panel state belongs to (spec §5.1).
2338    fn workspace_key(&self) -> String {
2339        self.document_name()
2340    }
2341
2342    /// Adopt the workspace state the document now open was last left with,
2343    /// filing away the one the document leaving carried. Called wherever the
2344    /// document is replaced or renamed, so a panel never follows the wrong
2345    /// document. Both of those are container doors, which the browser has
2346    /// none of.
2347    #[cfg(not(target_arch = "wasm32"))]
2348    fn settle_workspace(&mut self, was: &str) {
2349        self.workspaces.insert(was.to_owned(), self.workspace);
2350        let key = self.workspace_key();
2351        self.workspace = self.workspaces.get(&key).copied().unwrap_or_default();
2352    }
2353}
2354
2355impl eframe::App for App {
2356    /// Persist the appearance preferences to the eframe storage DB. eframe calls
2357    /// this periodically and on exit (the `persistence` feature is enabled); the
2358    /// blob is read back in `main` on the next launch.
2359    fn save(&mut self, storage: &mut dyn eframe::Storage) {
2360        match serde_json::to_string(&self.preferences) {
2361            Ok(s) => storage.set_string("preferences", s),
2362            Err(e) => tracing::error!("Failed to serialize preferences: {e}"),
2363        }
2364        self.workspaces.insert(self.workspace_key(), self.workspace);
2365        match serde_json::to_string(&self.workspaces) {
2366            Ok(s) => storage.set_string(WORKSPACES, s),
2367            Err(e) => tracing::error!("Failed to serialize workspaces: {e}"),
2368        }
2369        #[cfg(not(target_arch = "wasm32"))]
2370        {
2371            self.recent.save(storage);
2372        }
2373    }
2374
2375    // The dev editors write into the source tree, which the browser has no
2376    // access to — and eframe does not reliably call `on_exit` there anyway.
2377    // The document itself goes nowhere on exit: the log is the document
2378    // (F5), so there is nothing here to write back.
2379    #[cfg(not(target_arch = "wasm32"))]
2380    fn on_exit(&mut self) {
2381        // Persist theme tweaks if the editor was open when the app quit (closing
2382        // just the editor window already saved via `ui`).
2383        if self.theme_editor {
2384            self.save_theme();
2385        }
2386        if self.font_editor {
2387            self.save_font_sizes();
2388        }
2389        // A clean exit is one of the three moments the projection is
2390        // refreshed (D11) — the document itself needs nothing written, since
2391        // every commit was durable when it was made (F5).
2392        if self.session.doc.saving() == blockworx_store::doc::Saving::Offered {
2393            self.save_projection();
2394        }
2395        // A container's lock is its store's lifetime, and eframe does not
2396        // promise to drop the app on every platform: give it back here
2397        // rather than leave the next session to find a stale one.
2398        self.session.doc = Doc::default();
2399        // D20: a container this session invented and nobody wrote in goes
2400        // with it, so launching and quitting leaves no litter behind.
2401        self.sweep_unclaimed();
2402    }
2403
2404    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
2405        self.shell_frame(ui);
2406    }
2407}
2408
2409#[cfg(test)]
2410mod tests {
2411    use super::{App, AppConfig, BlockPath, Cursor, FontChoice, PointerOver, effective_cursor};
2412    use crate::canvas::convert::{IntoEgui as _, IntoGeom as _};
2413    use crate::tools::tool::Action;
2414    use blockworx_geom::{Pos2, Rect, Vec2, pos2, vec2};
2415
2416    /// egui's own Cmd+Plus/Minus/0 handler scales the whole UI, which would
2417    /// both fight the widget-size preference and swallow the canvas's zoom
2418    /// chords. Applying preferences turns it off.
2419    #[test]
2420    fn applying_preferences_leaves_the_zoom_chords_to_the_canvas() {
2421        let ctx = egui::Context::default();
2422        assert!(
2423            ctx.options(|o| o.zoom_with_keyboard),
2424            "egui's default changed; the override below may be moot"
2425        );
2426        let mut app = App::new(AppConfig::default());
2427        app.apply_preferences(&ctx);
2428        assert!(!ctx.options(|o| o.zoom_with_keyboard));
2429    }
2430
2431    /// D5: the app always holds a document. With nowhere to be born — the
2432    /// browser, and a session driving the editor without a filesystem —
2433    /// that is an in-process repo, open from the first frame.
2434    #[test]
2435    fn booting_opens_an_in_process_repo() {
2436        let app = App::new(AppConfig::default());
2437        assert_eq!(
2438            app.session.doc.repo().rev().get(),
2439            0,
2440            "a fresh repo starts on an empty document",
2441        );
2442    }
2443
2444    /// A context the whole shell can be driven in: the app's own fonts and
2445    /// the image loaders its icons need. A canvas pass that paints text into
2446    /// a context without them panics rather than laying out.
2447    fn shell_ctx() -> egui::Context {
2448        let ctx = egui::Context::default();
2449        ctx.set_fonts(crate::canvas::build_fonts(FontChoice::default()));
2450        egui_extras::install_image_loaders(&ctx);
2451        ctx
2452    }
2453
2454    /// Drive `app` through real frames on a `screen`-sized viewport, so the
2455    /// bands measure themselves and the canvas lands where they leave it.
2456    fn shell_frames(app: &mut App, ctx: &egui::Context, screen: Rect, frames: usize) {
2457        for frame in 0..frames {
2458            ctx.clone()
2459                .run_ui(
2460                    egui::RawInput {
2461                        screen_rect: Some(screen.egui()),
2462                        #[expect(
2463                            clippy::cast_precision_loss,
2464                            reason = "a frame count, not a measurement"
2465                        )]
2466                        // A coarse clock, so the shell's longest motion — the
2467                        // navigator's slide — is over before anything is measured.
2468                        time: Some(frame as f64 * 0.12),
2469                        ..Default::default()
2470                    },
2471                    |ui| app.shell_frame(ui),
2472                )
2473                .drop_without_applying_deltas();
2474        }
2475    }
2476
2477    /// The canvas runs edge to edge: nothing is docked, so the drawing
2478    /// surface *is* the window and the chrome floats over it (§2).
2479    #[test]
2480    fn the_canvas_runs_edge_to_edge_under_the_chrome() {
2481        use crate::shell::workspace::PanelView;
2482        let mut app = App::new(AppConfig::default());
2483        // The busy picture: the navigator open, which is the case that takes
2484        // the most room from the drawing.
2485        app.workspace.show(PanelView::History);
2486        let ctx = shell_ctx();
2487        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0));
2488        shell_frames(&mut app, &ctx, screen, 8);
2489
2490        let canvas = app.canvas.viewport();
2491        assert!(
2492            canvas.is_positive(),
2493            "the canvas never laid out: {canvas:?}"
2494        );
2495        assert_eq!(
2496            canvas.size(),
2497            screen.size(),
2498            "something is still docked: the canvas is {canvas:?} of {screen:?}",
2499        );
2500        assert_eq!(
2501            every_piece_laid_out(&ctx).len(),
2502            crate::shell::Berth::ALL.len(),
2503            "the frame drew fewer pieces than §2's table lists: {:?}",
2504            every_piece_laid_out(&ctx),
2505        );
2506    }
2507
2508    /// §2.1, the load-bearing one: a fit frames the model inside the region
2509    /// the *measured* chrome leaves, so nothing lands under a pill
2510    /// (invariant 4). Proved both ways — the model clears every piece that
2511    /// drew, and the same model centred on the raw viewport would not.
2512    #[test]
2513    fn a_fit_lands_the_model_clear_of_the_measured_chrome() {
2514        use crate::shell::workspace::PanelView;
2515        use crate::widget::test_fixtures as fx;
2516        let mut app = app_on(vec![
2517            fx::block_in(
2518                1,
2519                crate::path::Scope::Root,
2520                Rect::from_min_max(pos2(0.0, 0.0), pos2(120.0, 80.0)),
2521            ),
2522            fx::block_in(
2523                2,
2524                crate::path::Scope::Root,
2525                Rect::from_min_max(pos2(400.0, 300.0), pos2(520.0, 380.0)),
2526            ),
2527        ]);
2528        // The busy picture: the navigator open, which is the deepest inset
2529        // the frame has and the one a raw-viewport fit would land under.
2530        app.workspace.show(PanelView::History);
2531        let ctx = shell_ctx();
2532        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0));
2533        shell_frames(&mut app, &ctx, screen, 4);
2534        app.dispatch_action(&ctx, Action::ResetView);
2535        shell_frames(&mut app, &ctx, screen, 4);
2536
2537        let viewport = app.canvas.viewport();
2538        let region = app.safe.region();
2539        assert!(
2540            region.width() < viewport.width() && region.height() < viewport.height(),
2541            "precondition: the chrome measured something ({region:?} of {viewport:?})",
2542        );
2543        let chrome = every_piece_laid_out(&ctx);
2544        assert_eq!(
2545            chrome.len(),
2546            crate::shell::Berth::ALL.len(),
2547            "precondition: the frame drew every piece",
2548        );
2549
2550        let model = on_screen(&app, Rect::from_min_max(pos2(0.0, 0.0), pos2(520.0, 380.0)));
2551        for (berth, rect) in &chrome {
2552            let over = rect.intersect(model);
2553            assert!(
2554                over.width() <= 0.0 || over.height() <= 0.0,
2555                "{berth:?} at {rect:?} covers {over:?} of the model at {model:?}",
2556            );
2557        }
2558        // The other way round, so the first half cannot pass by the model
2559        // happening to be small: the framing *consulted* the chrome, which
2560        // shows as the same fit landing somewhere else once the deepest
2561        // piece is put away.
2562        app.workspace.close();
2563        shell_frames(&mut app, &ctx, screen, 4);
2564        app.dispatch_action(&ctx, Action::ResetView);
2565        shell_frames(&mut app, &ctx, screen, 4);
2566        let without_the_panel =
2567            on_screen(&app, Rect::from_min_max(pos2(0.0, 0.0), pos2(520.0, 380.0)));
2568        assert!(
2569            app.safe.region().width() > region.width(),
2570            "precondition: putting the navigator away gave the canvas its width back",
2571        );
2572        assert_ne!(
2573            without_the_panel, model,
2574            "the fit ignored the chrome: the model lands at {model:?} whether \
2575             or not the navigator is standing",
2576        );
2577    }
2578
2579    /// Where the drawing's own bounds land on screen, under the camera the
2580    /// last frame painted with.
2581    fn on_screen(app: &App, world: Rect) -> Rect {
2582        let origin = app.canvas.viewport().min;
2583        Rect::from_min_max(
2584            app.canvas.world_to_screen(origin, world.min),
2585            app.canvas.world_to_screen(origin, world.max),
2586        )
2587    }
2588
2589    /// The chrome floats, so the layers have to keep the canvas reachable
2590    /// under it: the pointer answers to the drawing everywhere the glass is
2591    /// not, and to the glass where it is. Nothing else in the suite drives a
2592    /// pointer through the whole frame, and a piece drawn in the wrong layer
2593    /// would either swallow the canvas or be swallowed by it.
2594    #[test]
2595    fn the_pointer_reaches_the_canvas_everywhere_the_chrome_is_not() {
2596        use crate::shell::workspace::PanelView;
2597        let mut app = App::new(AppConfig::default());
2598        app.workspace.show(PanelView::History);
2599        let ctx = shell_ctx();
2600        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0));
2601        shell_frames(&mut app, &ctx, screen, 8);
2602
2603        let clear = app.safe.region().center();
2604        assert!(
2605            hovering(&mut app, &ctx, screen, clear.geom()),
2606            "the canvas does not answer the pointer at {clear:?}, which no chrome covers",
2607        );
2608        let cluster = crate::shell::berth_rect(&ctx, crate::shell::Berth::ToolCluster)
2609            .expect("the tool cluster never laid out")
2610            .center();
2611        assert!(
2612            !hovering(&mut app, &ctx, screen, cluster.geom()),
2613            "the canvas answered a pointer that is over the tool cluster at {cluster:?}",
2614        );
2615    }
2616
2617    /// Whether the canvas reports the pointer as its own, with it resting at
2618    /// `at`. Hover is computed from the previous pass's widget rects, so the
2619    /// pointer is held there for more than one.
2620    fn hovering(app: &mut App, ctx: &egui::Context, screen: Rect, at: Pos2) -> bool {
2621        for _ in 0..3 {
2622            ctx.clone()
2623                .run_ui(
2624                    egui::RawInput {
2625                        screen_rect: Some(screen.egui()),
2626                        events: vec![egui::Event::PointerMoved(at.egui())],
2627                        ..Default::default()
2628                    },
2629                    |ui| app.shell_frame(ui),
2630                )
2631                .drop_without_applying_deltas();
2632        }
2633        app.canvas.canvas_hovered()
2634    }
2635
2636    /// §8's one gesture: the click that dismisses the navigator **also**
2637    /// performs the selection it landed
2638    /// on. Driven through the real frame — chrome first, then the canvas
2639    /// pass — and read off the canvas's own record of where it was last
2640    /// clicked, which is what every tool downstream of it acts on.
2641    #[test]
2642    fn the_click_that_dismisses_the_navigator_also_reaches_the_canvas() {
2643        use crate::shell::workspace::PanelView;
2644        let mut app = App::new(AppConfig::default());
2645        app.workspace.show(PanelView::History);
2646        let ctx = shell_ctx();
2647        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0));
2648        shell_frames(&mut app, &ctx, screen, 8);
2649        assert!(
2650            app.workspace.open() && app.session.clicked_world().is_none(),
2651            "precondition: the navigator is up and the canvas has taken no click",
2652        );
2653
2654        let away = app.safe.region().center();
2655        press_at(&mut app, &ctx, screen, away.geom());
2656        assert!(
2657            !app.workspace.open(),
2658            "the press away did not put the navigator down",
2659        );
2660        assert!(
2661            app.session.clicked_world().is_some(),
2662            "the dismissing press was swallowed instead of passing through",
2663        );
2664    }
2665
2666    /// The other half of the dismissal (§8): the filter is a transient
2667    /// search, so it does not survive the panel going away — which supersedes
2668    /// §9's "survives Escape" for this one control.
2669    #[test]
2670    fn the_hierarchy_filter_is_forgotten_when_the_navigator_is_dismissed() {
2671        use crate::shell::workspace::PanelView;
2672        let mut app = App::new(AppConfig::default());
2673        app.workspace.show(PanelView::Hierarchy);
2674        let ctx = shell_ctx();
2675        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0));
2676        shell_frames(&mut app, &ctx, screen, 8);
2677        let filter = crate::panels::nav_tree::nav_filter_id();
2678        ctx.data_mut(|d| d.insert_temp(filter, "boss".to_owned()));
2679        assert_eq!(
2680            ctx.data(|d| d.get_temp::<String>(filter)),
2681            Some("boss".to_owned()),
2682            "precondition: something is typed in the filter",
2683        );
2684
2685        let away = app.safe.region().center();
2686        press_at(&mut app, &ctx, screen, away.geom());
2687        assert!(
2688            !app.workspace.open(),
2689            "precondition: the panel was dismissed"
2690        );
2691        let left = ctx
2692            .data(|d| d.get_temp::<String>(filter))
2693            .unwrap_or_default();
2694        assert!(
2695            left.is_empty(),
2696            "the filter outlived the panel it belongs to: {left:?}",
2697        );
2698    }
2699
2700    /// §8's hand-off contract, one test per segment: every pick does its
2701    /// work *and leaves the navigator open*, so walking several revs or
2702    /// parts is one open rather than several. Driven through the whole real
2703    /// frame and clicked on what was actually painted.
2704    mod hand_off {
2705        use super::{App, AppConfig, app_on, screen_of};
2706        use crate::canvas::convert::IntoEgui as _;
2707        use crate::panels::painted::Chrome;
2708        use crate::shell::workspace::PanelView;
2709        use crate::tools::tool::ToolTrait as _;
2710        use crate::widget::test_fixtures as fx;
2711        use blockworx_doc::fixtures::block_id;
2712        use blockworx_geom::{Pos2, Rect, pos2};
2713
2714        /// A document with a named block at the root and a named one inside
2715        /// it — a scope to enter, and a part to select.
2716        fn nested() -> App {
2717            use crate::path::Scope;
2718            let body = |x: f32| Rect::from_min_max(pos2(x, 0.0), pos2(x + 60.0, 60.0));
2719            let mut app = app_on(vec![
2720                fx::block_in(1, Scope::Root, body(0.0)),
2721                fx::titled(1, "Motor"),
2722                fx::block_in(2, Scope::Block(block_id(1)), body(8.0)),
2723                fx::titled(2, "Filter"),
2724            ]);
2725            app.workspace.show(PanelView::Hierarchy);
2726            app
2727        }
2728
2729        /// The panel, open on `view`, drawn through real frames of the whole
2730        /// shell.
2731        fn browsing(app: &mut App, view: PanelView) -> Chrome {
2732            app.workspace.show(view);
2733            let mut chrome = Chrome::new(screen_of());
2734            chrome.settle(|ui| app.shell_frame(ui));
2735            chrome
2736        }
2737
2738        /// Where a row *inside the panel* landed — the lowest match, since
2739        /// the filter box above the list draws whatever was typed into it.
2740        /// The canvas draws these names too, so a test that aimed at the
2741        /// first match anywhere would be clicking the drawing and proving the
2742        /// opposite of what it claims.
2743        fn row(chrome: &Chrome, starting: &str) -> Pos2 {
2744            let panel = crate::shell::berth_rect(chrome.ctx(), crate::shell::Berth::Navigator)
2745                .expect("the navigator never laid out");
2746            let painted: Vec<String> = chrome
2747                .texts()
2748                .iter()
2749                .filter(|said| said.starts_with(starting))
2750                .map(|said| (*said).to_owned())
2751                .collect();
2752            painted
2753                .iter()
2754                .flat_map(|said| chrome.rects(said))
2755                .filter(|rect| panel.contains_rect(rect.egui()))
2756                .max_by(|a, b| a.top().total_cmp(&b.top()))
2757                .unwrap_or_else(|| {
2758                    panic!(
2759                        "no row in the panel reads {starting:?}: {:?}",
2760                        chrome.texts()
2761                    )
2762                })
2763                .center()
2764        }
2765
2766        /// History: a rev pick opens the lens *behind* the panel, which stays
2767        /// up — the row is a lens, not a jump out of browsing.
2768        #[test]
2769        fn a_rev_pick_opens_the_lens_and_keeps_the_panel_open() {
2770            let mut app = app_on(vec![fx::block(1, 0.0)]);
2771            app.session.submit(blockworx_doc::commit::Commit::new(
2772                "Moved to 5".into(),
2773                vec![blockworx_store::fixture::block_move(1, 5)],
2774            ));
2775            let mut chrome = browsing(&mut app, PanelView::History);
2776            assert_eq!(
2777                app.session.viewing(),
2778                blockworx_store::doc::Viewing::Head,
2779                "precondition: the canvas is at the present",
2780            );
2781
2782            let at = row(&chrome, "Built a scene");
2783            chrome.click_at(at, |ui| app.shell_frame(ui));
2784            assert!(
2785                matches!(
2786                    app.session.viewing(),
2787                    blockworx_store::doc::Viewing::Past(_)
2788                ),
2789                "the rev pick did not open the lens",
2790            );
2791            assert!(
2792                app.workspace.open(),
2793                "the rev pick closed the panel it was picked from",
2794            );
2795        }
2796
2797        /// Hierarchy: a part pick becomes a canvas selection — which is what
2798        /// raises the selection overlay — and the panel stays up.
2799        #[test]
2800        fn a_part_pick_selects_on_the_canvas_and_keeps_the_panel_open() {
2801            let mut app = nested();
2802            let mut chrome = browsing(&mut app, PanelView::Hierarchy);
2803            assert!(
2804                app.session.tool.selection().is_none(),
2805                "precondition: nothing is selected",
2806            );
2807
2808            let at = row(&chrome, "Motor");
2809            chrome.click_at(at, |ui| app.shell_frame(ui));
2810            assert_eq!(
2811                app.session.tool.selection().map(|sel| sel.count()),
2812                Some(1),
2813                "the part pick did not select on the canvas",
2814            );
2815            assert!(
2816                app.workspace.open(),
2817                "the part pick closed the panel it was picked from",
2818            );
2819        }
2820
2821        /// Hierarchy again: picking a block that lives inside another makes
2822        /// its level the edit context, and the bar's breadcrumb says so. The
2823        /// filter is what reaches it — §8.2's own way of crossing a level.
2824        #[test]
2825        fn a_scope_pick_changes_the_context_and_the_breadcrumb_follows() {
2826            let mut app = nested();
2827            let mut chrome = browsing(&mut app, PanelView::Hierarchy);
2828            assert!(
2829                app.session.path.segments().is_empty(),
2830                "precondition: the canvas is at the document root",
2831            );
2832            chrome.ctx().data_mut(|d| {
2833                d.insert_temp(
2834                    crate::panels::nav_tree::nav_filter_id(),
2835                    "Filter".to_owned(),
2836                );
2837            });
2838            chrome.settle(|ui| app.shell_frame(ui));
2839
2840            let at = row(&chrome, "Filter");
2841            chrome.click_at(at, |ui| app.shell_frame(ui));
2842            assert_eq!(
2843                app.session.path.segments(),
2844                &[block_id(1)],
2845                "the pick did not change the edit context",
2846            );
2847            assert!(
2848                app.workspace.open(),
2849                "the scope pick closed the panel it was picked from",
2850            );
2851            chrome.settle(|ui| app.shell_frame(ui));
2852            assert!(
2853                chrome.shows("Motor"),
2854                "the breadcrumb does not name the level the pick entered: {:?}",
2855                chrome.texts(),
2856            );
2857        }
2858
2859        /// §8's other dismissal: a tool pick is intent to edit, so the panel
2860        /// goes — the one thing outside the canvas that closes it.
2861        #[test]
2862        fn a_tool_pick_dismisses_the_panel() {
2863            let mut app = App::new(AppConfig::default());
2864            let mut chrome = browsing(&mut app, PanelView::History);
2865            let column = crate::shell::berth_rect(chrome.ctx(), crate::shell::Berth::ToolCluster)
2866                .expect("the tool rail never laid out");
2867            assert!(app.workspace.open(), "precondition: the panel is up");
2868
2869            chrome.click_at(
2870                pos2(
2871                    column.center().x,
2872                    column.top() + crate::shell::glass::TOOL.y * 1.5,
2873                ),
2874                |ui| app.shell_frame(ui),
2875            );
2876            assert!(!app.workspace.open(), "a tool pick left the panel standing");
2877        }
2878    }
2879
2880    /// The tool cluster's other gesture (playbook R39): a cell pressed,
2881    /// carried onto the canvas, and dropped there stamps the tool's default
2882    /// thing where it landed — *"If the user drags the tool off the toolbar,
2883    /// then use the current 'stamp' behavior."* Driven through the whole
2884    /// frame, because the drop crosses three seams (the cell's drag sense,
2885    /// the chrome's own boxes, the canvas transform) and none of them can be
2886    /// judged alone.
2887    mod drag_out {
2888        use super::{App, AppConfig, press_at, screen_of, shell_ctx};
2889        use crate::canvas::convert::{IntoEgui as _, IntoGeom as _};
2890        use crate::tools::names::{ToolName, displayed_tool};
2891        use crate::tools::tool::{Action, Tool, ToolTrait as _};
2892        use blockworx_geom::{Pos2, Rect, pos2};
2893
2894        /// A session with the shell measured and Select armed.
2895        fn session() -> (App, egui::Context, Rect) {
2896            let mut app = App::new(AppConfig::default());
2897            let ctx = shell_ctx();
2898            let screen = screen_of();
2899            frames(&mut app, &ctx, screen, 8, &[]);
2900            (app, ctx, screen)
2901        }
2902
2903        /// `count` frames carrying `events` in the first of them, on the
2904        /// clock the session is already keeping — a drag reads back over
2905        /// several frames, and a clock that restarted would be a session
2906        /// travelling backwards in time.
2907        fn frames(
2908            app: &mut App,
2909            ctx: &egui::Context,
2910            screen: Rect,
2911            count: usize,
2912            events: &[egui::Event],
2913        ) {
2914            for frame in 0..count {
2915                let now = ctx.input(|i| i.time) + 0.12;
2916                ctx.clone()
2917                    .run_ui(
2918                        egui::RawInput {
2919                            screen_rect: Some(screen.egui()),
2920                            time: Some(now),
2921                            events: if frame == 0 {
2922                                events.to_owned()
2923                            } else {
2924                                Vec::new()
2925                            },
2926                            ..Default::default()
2927                        },
2928                        |ui| app.shell_frame(ui),
2929                    )
2930                    .drop_without_applying_deltas();
2931            }
2932        }
2933
2934        /// Where the cluster's `tool` cell landed, found by arming it: a
2935        /// guessed point that armed something else would make every drag
2936        /// below a drag from nowhere, and this fails instead.
2937        fn cell_of(app: &mut App, ctx: &egui::Context, screen: Rect, tool: ToolName) -> Pos2 {
2938            let column = crate::shell::berth_rect(ctx, crate::shell::Berth::ToolCluster)
2939                .expect("the tool cluster never laid out");
2940            let mut y = column.top();
2941            while y <= column.bottom() {
2942                let at = pos2(column.center().x, y);
2943                press_at(app, ctx, screen, at);
2944                if app.session.tool.name() == tool {
2945                    app.dispatch_action(ctx, Action::SwitchTool(Tool::from_name(ToolName::Select)));
2946                    frames(app, ctx, screen, 2, &[]);
2947                    return at;
2948                }
2949                y += 8.0;
2950            }
2951            panic!("no point down the cluster arms {tool:?}");
2952        }
2953
2954        /// A press at `from` carried to `to` and released there. egui tells a
2955        /// drag from a click by how far the pointer travelled, so the
2956        /// half-way move is what makes this the other gesture.
2957        fn drag(app: &mut App, ctx: &egui::Context, screen: Rect, from: Pos2, to: Pos2) {
2958            let button = |pos, pressed| egui::Event::PointerButton {
2959                pos,
2960                button: egui::PointerButton::Primary,
2961                pressed,
2962                modifiers: egui::Modifiers::NONE,
2963            };
2964            for events in [
2965                vec![egui::Event::PointerMoved(from.egui())],
2966                vec![button(from.egui(), true)],
2967                vec![egui::Event::PointerMoved((from + (to - from) * 0.5).egui())],
2968                vec![egui::Event::PointerMoved(to.egui())],
2969                vec![button(to.egui(), false)],
2970            ] {
2971                frames(app, ctx, screen, 1, &events);
2972            }
2973            frames(app, ctx, screen, 2, &[]);
2974        }
2975
2976        /// Every title the current level holds, so a stamp can be told from
2977        /// nothing at all.
2978        fn titles(app: &mut App) -> Vec<String> {
2979            app.session
2980                .drawing()
2981                .shapes()
2982                .filter_map(|(_, shape)| shape.title().map(|t| t.name.to_owned()))
2983                .collect()
2984        }
2985
2986        /// The ruling's own case: a block dropped on the canvas is the block
2987        /// a drag would have drawn — born named (R17), on the grid under the
2988        /// drop, in one commit.
2989        #[test]
2990        fn a_block_dropped_on_the_canvas_lands_where_it_was_dropped() {
2991            let (mut app, ctx, screen) = session();
2992            let cell = cell_of(&mut app, &ctx, screen, ToolName::NewBlock);
2993            let onto = app.safe.region().center();
2994            let before = app.session.doc.repo().rev();
2995            assert!(
2996                titles(&mut app).is_empty(),
2997                "precondition: nothing is drawn"
2998            );
2999
3000            drag(&mut app, &ctx, screen, cell, onto.geom());
3001
3002            assert_eq!(
3003                titles(&mut app),
3004                vec!["Block 1".to_owned()],
3005                "the drop stamped no block, or stamped an unnamed one",
3006            );
3007            assert_eq!(
3008                app.session.doc.repo().rev().get(),
3009                before.get() + 1,
3010                "a stamp is one commit",
3011            );
3012            let world = app.canvas.screen_to_world_pos(onto.geom());
3013            let wanted = crate::edit::create::stamped_block(world);
3014            let placed = app
3015                .session
3016                .drawing()
3017                .shapes()
3018                .map(|(_, shape)| shape.gui_rect())
3019                .next()
3020                .expect("the stamped block");
3021            assert_eq!(
3022                placed.min, wanted.min,
3023                "the block did not land under the drop"
3024            );
3025        }
3026
3027        /// The two gestures are distinct: carrying a cell off the cluster
3028        /// leaves the armed cell alone, so the drag-out is not a slow click.
3029        #[test]
3030        fn a_drag_out_does_not_arm_the_tool_it_carried() {
3031            let (mut app, ctx, screen) = session();
3032            let cell = cell_of(&mut app, &ctx, screen, ToolName::NewBlock);
3033            assert_eq!(
3034                displayed_tool(app.session.tool.name()),
3035                ToolName::Select,
3036                "precondition: the cluster shows Select armed before the drag",
3037            );
3038
3039            let onto = app.safe.region().center();
3040            drag(&mut app, &ctx, screen, cell, onto.geom());
3041
3042            assert_eq!(
3043                displayed_tool(app.session.tool.name()),
3044                ToolName::Select,
3045                "the drag-out armed New Block as well as stamping one",
3046            );
3047        }
3048
3049        /// *"A route cannot exist without it's endpoints."* — so carrying
3050        /// the route cell out does nothing at all, and the cell springs
3051        /// back.
3052        #[test]
3053        fn a_route_dropped_on_the_canvas_creates_nothing() {
3054            let (mut app, ctx, screen) = session();
3055            let cell = cell_of(&mut app, &ctx, screen, ToolName::Route);
3056            let before = app.session.doc.repo().rev();
3057
3058            let onto = app.safe.region().center();
3059            drag(&mut app, &ctx, screen, cell, onto.geom());
3060
3061            assert_eq!(
3062                app.session.doc.repo().rev(),
3063                before,
3064                "dropping the route tool wrote to the log",
3065            );
3066        }
3067
3068        /// A drop that never reached the canvas is no drop: released over
3069        /// the glass, the gesture ends and nothing is made.
3070        #[test]
3071        fn a_drop_on_the_chrome_stamps_nothing() {
3072            let (mut app, ctx, screen) = session();
3073            let cell = cell_of(&mut app, &ctx, screen, ToolName::NewBlock);
3074            let onto = crate::shell::berth_rect(&ctx, crate::shell::Berth::TopBar)
3075                .expect("the top bar never laid out")
3076                .center();
3077            assert!(
3078                crate::shell::over_the_chrome(&ctx, onto),
3079                "precondition: the release point is on a piece of chrome",
3080            );
3081            let before = app.session.doc.repo().rev();
3082
3083            drag(&mut app, &ctx, screen, cell, onto.geom());
3084
3085            assert_eq!(
3086                app.session.doc.repo().rev(),
3087                before,
3088                "a drop on the chrome reached the document",
3089            );
3090            assert!(titles(&mut app).is_empty());
3091        }
3092    }
3093
3094    /// The viewport every shell test drives.
3095    fn screen_of() -> Rect {
3096        Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0))
3097    }
3098
3099    /// One full primary press and release at `at`, through the real frame.
3100    /// The clock runs on across the presses, so egui does not read two of
3101    /// them as one double click.
3102    fn press_at(app: &mut App, ctx: &egui::Context, screen: Rect, at: Pos2) {
3103        let button = |pressed| egui::Event::PointerButton {
3104            pos: at.egui(),
3105            button: egui::PointerButton::Primary,
3106            pressed,
3107            modifiers: egui::Modifiers::NONE,
3108        };
3109        for events in [
3110            vec![egui::Event::PointerMoved(at.egui())],
3111            vec![button(true), button(false)],
3112            Vec::new(),
3113            Vec::new(),
3114        ] {
3115            let now = ctx.input(|i| i.time) + 0.12;
3116            ctx.clone()
3117                .run_ui(
3118                    egui::RawInput {
3119                        screen_rect: Some(screen.egui()),
3120                        time: Some(now),
3121                        events,
3122                        ..Default::default()
3123                    },
3124                    |ui| app.shell_frame(ui),
3125                )
3126                .drop_without_applying_deltas();
3127        }
3128    }
3129
3130    /// One shell frame carrying a key press.
3131    fn press_key(app: &mut App, ctx: &egui::Context, screen: Rect, key: egui::Key) {
3132        ctx.clone()
3133            .run_ui(
3134                egui::RawInput {
3135                    screen_rect: Some(screen.egui()),
3136                    time: Some(ctx.input(|i| i.time) + 0.12),
3137                    events: vec![egui::Event::Key {
3138                        key,
3139                        physical_key: None,
3140                        pressed: true,
3141                        repeat: false,
3142                        modifiers: egui::Modifiers::NONE,
3143                    }],
3144                    ..Default::default()
3145                },
3146                |ui| app.shell_frame(ui),
3147            )
3148            .drop_without_applying_deltas();
3149    }
3150
3151    /// §2.0.1's four states, walked through the whole shell in the order the
3152    /// priority puts them: the idle readout, the selection path over it, the
3153    /// armed tool's instruction over that, and a confirmation over
3154    /// everything — each reached the way a user reaches it.
3155    #[test]
3156    fn the_status_line_reads_all_four_of_its_states_in_priority_order() {
3157        use crate::tools::names::ToolName;
3158        use crate::widget::test_fixtures as fx;
3159        let block = blockworx_doc::fixtures::block_id(1);
3160        let mut app = app_on(vec![fx::block(1, 0.0), fx::titled(1, "Motor")]);
3161        let ctx = shell_ctx();
3162        let screen = screen_of();
3163        shell_frames(&mut app, &ctx, screen, 4);
3164        let said = shell_text(&mut app, &ctx, screen);
3165        assert!(
3166            said.iter().any(|word| word == "100%"),
3167            "the idle line does not read the zoom (R46): {said:?}",
3168        );
3169
3170        app.dispatch_action(
3171            &ctx,
3172            Action::NavSelect {
3173                block,
3174                extend: false,
3175            },
3176        );
3177        shell_frames(&mut app, &ctx, screen, 2);
3178        let said = shell_text(&mut app, &ctx, screen);
3179        assert!(
3180            said.iter().any(|word| word.contains("Motor")),
3181            "a selection does not put its path on the line: {said:?}",
3182        );
3183
3184        app.dispatch_action(
3185            &ctx,
3186            Action::SwitchTool(crate::tools::tool::Tool::from_name(ToolName::NewArea)),
3187        );
3188        shell_frames(&mut app, &ctx, screen, 2);
3189        let said = shell_text(&mut app, &ctx, screen);
3190        let instruction =
3191            crate::tools::names::instruction(ToolName::NewArea).expect("a creator instructs");
3192        assert!(
3193            said.iter().any(|word| word == instruction),
3194            "an armed tool does not instruct: {said:?}",
3195        );
3196
3197        // And an edit outranks all three, for its two seconds.
3198        app.dispatch_action(
3199            &ctx,
3200            Action::SwitchTool(crate::tools::tool::Tool::from_name(ToolName::Select)),
3201        );
3202        app.dispatch_action(
3203            &ctx,
3204            Action::NavSelect {
3205                block,
3206                extend: false,
3207            },
3208        );
3209        let stood = app.session.doc.repo().rev();
3210        press_key(&mut app, &ctx, screen, egui::Key::ArrowRight);
3211        assert_ne!(
3212            app.session.doc.repo().rev(),
3213            stood,
3214            "precondition: the arrow key authored an edit",
3215        );
3216        let said = shell_text(&mut app, &ctx, screen);
3217        let rev = format!("rev {}", app.session.doc.repo().rev().get());
3218        assert!(
3219            said.iter().any(|word| word.contains(&rev)),
3220            "the confirmation does not name the rev it wrote: {said:?}",
3221        );
3222    }
3223
3224    /// R51: the title block names the rev *on the canvas*, so opening the
3225    /// lens changes what it says. The author rides with it, since it is the
3226    /// session's own and not the rev's.
3227    #[test]
3228    fn the_title_block_names_the_author_and_the_rev_the_canvas_is_showing() {
3229        use crate::widget::test_fixtures as fx;
3230        let mut app = App::new(AppConfig::default());
3231        // Two commits, so there is an earlier rev for the lens to stand on.
3232        let commits = [
3233            blockworx_doc::commit::Commit::new("Built a scene".into(), vec![fx::block(1, 0.0)]),
3234            blockworx_doc::commit::Commit::new("Named it".into(), vec![fx::titled(1, "Motor")]),
3235        ];
3236        app.session.adopt(blockworx_store::doc::Doc::scratch(
3237            blockworx_doc::repo::Repo::folding(&commits).expect("the scene folds"),
3238        ));
3239        app.session.path = BlockPath::opening(app.session.doc.document());
3240        app.session.identity = blockworx_store::record::Identity::new("Ada Lovelace");
3241        let ctx = shell_ctx();
3242        let screen = screen_of();
3243        shell_frames(&mut app, &ctx, screen, 4);
3244        let head = app.session.doc.repo().rev();
3245        assert!(head.get() > 1, "precondition: the log has revs to walk");
3246        let names = |app: &mut App, ctx: &egui::Context, rev: u64| {
3247            shell_text(app, ctx, screen)
3248                .iter()
3249                .any(|said| said.contains("Ada Lovelace") && said.contains(&format!("rev {rev}")))
3250        };
3251        assert!(
3252            names(&mut app, &ctx, head.get()),
3253            "the title block does not name the author and the head",
3254        );
3255
3256        app.dispatch_action(&ctx, Action::ViewRev(blockworx_doc::fixtures::rev(1)));
3257        shell_frames(&mut app, &ctx, screen, 2);
3258        assert!(
3259            names(&mut app, &ctx, 1),
3260            "under the lens the title block still names the head",
3261        );
3262    }
3263
3264    /// R53 at the app's own door: the folder picker takes any folder, so a
3265    /// choice that is not a diagram is refused where the user can read it —
3266    /// and the session it would have replaced is left standing.
3267    #[cfg(not(target_arch = "wasm32"))]
3268    #[test]
3269    fn opening_a_folder_that_is_no_diagram_refuses_and_keeps_the_session() {
3270        use crate::widget::test_fixtures as fx;
3271        let dir = blockworx_store::temp::TempDir::new("open-refusal");
3272        let plain = dir.join("not-a-diagram");
3273        std::fs::create_dir_all(&plain).expect("the directory");
3274        let mut app = app_on(vec![fx::block(1, 0.0), fx::titled(1, "Motor")]);
3275        let stood = app.session.doc.repo().rev();
3276
3277        app.open_container(&plain);
3278
3279        let said: Vec<String> = app
3280            .notices()
3281            .into_iter()
3282            .map(|notice| match notice {
3283                crate::panels::notices::Notice::Failure(said)
3284                | crate::panels::notices::Notice::Standing(said) => said,
3285            })
3286            .collect();
3287        assert!(
3288            said.iter().any(|notice| notice.contains("not a diagram")),
3289            "the refusal never reached the user: {said:?}",
3290        );
3291        assert!(
3292            said.iter().all(|notice| !notice.contains("log.jsonl")),
3293            "the refusal answers a question nobody asked: {said:?}",
3294        );
3295        assert_eq!(
3296            app.session.doc.repo().rev(),
3297            stood,
3298            "a refused pick replaced the session anyway",
3299        );
3300    }
3301
3302    /// Every piece of floating chrome the last frame laid out, by berth.
3303    fn every_piece_laid_out(ctx: &egui::Context) -> Vec<(crate::shell::Berth, Rect)> {
3304        crate::shell::Berth::ALL
3305            .into_iter()
3306            .filter_map(|berth| {
3307                crate::shell::berth_rect(ctx, berth).map(|rect| (berth, rect.geom()))
3308            })
3309            .collect()
3310    }
3311
3312    /// §9, verified rather than built: the tablet is the same layout, and what
3313    /// makes it a tablet layout is the 44px target, which carries the claim
3314    /// alone (R47).
3315    ///
3316    /// Both widths §9 names, in points, since points and pixels are now the
3317    /// same thing: the chrome still takes its room from the canvas rather than
3318    /// from itself, and every cell is still a fingertip wide.
3319    #[test]
3320    fn the_tablet_widths_hold_at_the_taps_own_size() {
3321        use crate::shell::workspace::PanelView;
3322        for points in [1024.0, 834.0] {
3323            let mut app = App::new(AppConfig::default());
3324            app.workspace.show(PanelView::History);
3325            let ctx = shell_ctx();
3326            let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(points, 768.0));
3327            shell_frames(&mut app, &ctx, screen, 8);
3328            assert_eq!(
3329                ctx.zoom_factor(),
3330                1.0,
3331                "nothing scales the UI any more (R47), at {points}pt",
3332            );
3333
3334            let canvas = app.canvas.viewport();
3335            assert!(
3336                canvas.is_positive(),
3337                "the canvas never laid out at {points}pt: {canvas:?}",
3338            );
3339            let chrome = every_piece_laid_out(&ctx);
3340            assert_eq!(
3341                chrome.len(),
3342                crate::shell::Berth::ALL.len(),
3343                "the frame lost a piece at {points}pt: {chrome:?}",
3344            );
3345            for (berth, rect) in &chrome {
3346                assert!(
3347                    canvas.contains_rect(*rect),
3348                    "{berth:?} at {rect:?} hangs off the window at {points}pt",
3349                );
3350            }
3351            assert!(
3352                app.safe.region().is_positive(),
3353                "the chrome swallowed the canvas at {points}pt",
3354            );
3355            let cluster = chrome
3356                .iter()
3357                .find_map(|(berth, rect)| {
3358                    (*berth == crate::shell::glass::Berth::ToolCluster).then_some(*rect)
3359                })
3360                .unwrap_or_else(|| panic!("the cluster never laid out at {points}pt"));
3361            let cells = crate::tools::names::BAND_TOOLS.len() as f32;
3362            assert!(
3363                cluster.height() >= cells * crate::shell::glass::TOOL.y,
3364                "a tool fell off the cluster at {points}pt: {} tall for {cells} cells",
3365                cluster.height(),
3366            );
3367            assert!(
3368                crate::shell::glass::TOOL.x >= crate::shell::glass::TAP
3369                    && cluster.width() >= crate::shell::glass::TAP,
3370                "the cluster is under a fingertip at {points}pt: {} wide",
3371                cluster.width(),
3372            );
3373        }
3374    }
3375
3376    /// The whole idle frame must settle: stable shapes and no immediate
3377    /// repaint request. The chrome this replaced had exactly this guard, and
3378    /// a band that re-measures itself every frame redraws the editor at full
3379    /// rate while nothing is happening.
3380    #[test]
3381    fn the_idle_frame_settles() {
3382        use crate::shell::workspace::PanelView;
3383        let mut app = App::new(AppConfig::default());
3384        app.workspace.show(PanelView::History);
3385        let settle = crate::canvas::settle::probe(30, |ui| app.shell_frame(ui));
3386        crate::canvas::settle::assert_settles(&settle, 8);
3387    }
3388
3389    /// The rail is the one way into either panel body, and switching between
3390    /// them swaps what the panel says — driven through real frames, so a
3391    /// body that laid out but never drew fails here.
3392    #[test]
3393    fn the_rail_opens_each_panel_body_in_turn() {
3394        use crate::shell::workspace::PanelView;
3395        let mut app = App::new(AppConfig::default());
3396        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0));
3397        for (view, title) in [
3398            (PanelView::History, "History"),
3399            (PanelView::Hierarchy, "Hierarchy"),
3400        ] {
3401            let ctx = shell_ctx();
3402            app.workspace.show(view);
3403            shell_frames(&mut app, &ctx, screen, 4);
3404            let said = shell_text(&mut app, &ctx, screen);
3405            assert!(
3406                said.iter().any(|text| text == title),
3407                "the {title} panel never drew its own header: {said:?}",
3408            );
3409        }
3410    }
3411
3412    /// Every word one shell frame actually painted — a body that laid out
3413    /// but drew nothing is absent from this.
3414    fn shell_text(app: &mut App, ctx: &egui::Context, screen: Rect) -> Vec<String> {
3415        let mut said = Vec::new();
3416        let mut out = ctx.clone().run_ui(
3417            egui::RawInput {
3418                screen_rect: Some(screen.egui()),
3419                ..Default::default()
3420            },
3421            |ui| app.shell_frame(ui),
3422        );
3423        out.textures_delta.clear();
3424        for clipped in &out.shapes {
3425            collect_text(&clipped.shape, &mut said);
3426        }
3427        out.drop_without_applying_deltas();
3428        said
3429    }
3430
3431    /// Every text run in a frame's output, flattened.
3432    fn collect_text(shape: &egui::Shape, out: &mut Vec<String>) {
3433        match shape {
3434            egui::Shape::Text(text) => out.push(text.galley.text().to_owned()),
3435            egui::Shape::Vec(shapes) => {
3436                for shape in shapes {
3437                    collect_text(shape, out);
3438                }
3439            }
3440            _ => {}
3441        }
3442    }
3443
3444    /// A document file named on the command line opens as a scratch
3445    /// session, and names the window after itself.
3446    #[cfg(not(target_arch = "wasm32"))]
3447    #[test]
3448    fn the_path_argument_opens_and_names_the_window() {
3449        use blockworx_store::temp::TempDir;
3450
3451        let dir = TempDir::new("app-opens-the-path");
3452        let file = dir.join("d.json");
3453        std::fs::write(
3454            &file,
3455            r#"{"version": 3, "top": "b1",
3456                "blocks": {"b1": {"rect": {"size": {"w": 8, "h": 8}}}}}"#,
3457        )
3458        .unwrap();
3459
3460        let opened = App::new(AppConfig {
3461            opening: crate::app::Opening::Path(file),
3462            ..Default::default()
3463        });
3464        assert!(
3465            opened.window_title().starts_with("d.json - "),
3466            "the file opens and names the window: {}",
3467            opened.window_title(),
3468        );
3469        assert!(
3470            opened.session.doc.repo().rev().get() > 0,
3471            "and its commits seeded the repo",
3472        );
3473    }
3474
3475    // ── The app-level action arms ────────────────────────────────────────────
3476    //
3477    // Step 13's straggler list called wrap top and the keyboard nudges
3478    // ordinary tool flows. They are not: both read app state a tool does not
3479    // hold — the block path, and the tool's own selection — so
3480    // `commands::apply_scripted` hands them back and `dispatch_action`
3481    // resolves them here. These drive that dispatch, which is the real path
3482    // the toolbar and the arrow keys both take.
3483
3484    /// An app holding `ops` as its document, viewed from the document root.
3485    /// Seeded through a `Repo` for the same reason every other document
3486    /// reaches the editor that way: the log *is* the document (F5), so there
3487    /// is no other door to put one behind.
3488    fn app_on(ops: Vec<blockworx_doc::opcode::OpCodes>) -> App {
3489        let mut app = App::new(AppConfig::default());
3490        let commit = blockworx_doc::commit::Commit::new("Built a scene".into(), ops);
3491        app.session.adopt(blockworx_store::doc::Doc::scratch(
3492            blockworx_doc::repo::Repo::folding(&[commit]).expect("the scene folds"),
3493        ));
3494        app.session.path = BlockPath::opening(app.session.doc.document());
3495        app
3496    }
3497
3498    /// The label that reaches the log is minted at the seal, from the ops
3499    /// the gesture turned out to author — so it names the entity and the
3500    /// scope. Driven through the real dispatch, because that is the only
3501    /// place the verb, the ops, and the block path are all in one room.
3502    #[test]
3503    fn a_dispatched_edit_lands_under_a_label_naming_what_it_touched() {
3504        use crate::path::Scope;
3505        use crate::shape::ShapeId;
3506        use crate::tools::tool::Deletable;
3507        use crate::widget::test_fixtures as fx;
3508
3509        let ctx = egui::Context::default();
3510        let mut app = app_on(vec![
3511            fx::block(1, 0.0),
3512            fx::titled(1, "Amplifier"),
3513            fx::block_in(
3514                2,
3515                Scope::Block(blockworx_doc::fixtures::block_id(1)),
3516                Rect::from_min_max(pos2(4.0, 4.0), pos2(24.0, 24.0)),
3517            ),
3518            fx::titled(2, "Filter"),
3519            fx::top(1),
3520        ]);
3521        assert_eq!(
3522            app.session.path.segments(),
3523            [blockworx_doc::fixtures::block_id(1)],
3524            "precondition: the editor opens inside the named top block",
3525        );
3526        let before = app.session.doc.repo().rev();
3527
3528        app.dispatch_action(
3529            &ctx,
3530            Action::Delete(Deletable::Shape(ShapeId::Rect(
3531                blockworx_doc::fixtures::block_id(2),
3532            ))),
3533        );
3534
3535        assert_ne!(
3536            app.session.doc.repo().rev(),
3537            before,
3538            "the delete reached the log"
3539        );
3540        assert_eq!(
3541            app.session
3542                .doc
3543                .repo()
3544                .log()
3545                .last()
3546                .expect("the commit")
3547                .label(),
3548            "Delete block \u{201c}Filter\u{201d}",
3549        );
3550    }
3551
3552    /// Spec §7 through the real dispatch: two kinds of entry on one stack,
3553    /// the camera and the scope among them.
3554    mod two_kinds_one_stack {
3555        use super::{App, app_on};
3556        use crate::canvas::Vantage;
3557        use crate::history::{COALESCE, Direction, Moved};
3558        use crate::shape::ShapeId;
3559        use crate::tools::tool::Action;
3560        use blockworx_geom::{Rect, pos2, vec2};
3561        use blockworx_paint::Zoom;
3562        use core::time::Duration;
3563
3564        /// A scene with something to edit inside the scope the editor opens
3565        /// in, so an edit and a camera move can interleave over one document.
3566        fn scene() -> App {
3567            use crate::path::Scope;
3568            use crate::widget::test_fixtures as fx;
3569            app_on(vec![
3570                fx::block(1, 0.0),
3571                fx::titled(1, "Amplifier"),
3572                fx::block_in(
3573                    2,
3574                    Scope::Block(blockworx_doc::fixtures::block_id(1)),
3575                    Rect::from_min_max(pos2(4.0, 4.0), pos2(24.0, 24.0)),
3576                ),
3577                fx::titled(2, "Filter"),
3578                fx::top(1),
3579            ])
3580        }
3581
3582        /// One frame of history bookkeeping, at `at` on the session's clock —
3583        /// the same two calls `App::show` makes around its dispatch.
3584        fn tick(app: &mut App, ctx: &egui::Context, at: Duration, action: Option<Action>) {
3585            app.sync_camera(ctx);
3586            let before = app.session.state();
3587            if let Some(action) = action {
3588                app.dispatch_action(ctx, action);
3589            }
3590            app.sync_camera(ctx);
3591            app.session.record_history(&before, at);
3592        }
3593
3594        /// Hold still long enough for whatever just happened to become an
3595        /// entry of its own (§7.2's window).
3596        fn settle(app: &mut App, ctx: &egui::Context, at: Duration) -> Duration {
3597            tick(app, ctx, at, None);
3598            let at = at + COALESCE + COALESCE;
3599            tick(app, ctx, at, None);
3600            at
3601        }
3602
3603        fn look_at(app: &mut App, x: f32) {
3604            app.canvas.stand_at(Vantage {
3605                zoom: Zoom::new(2.0),
3606                translation: vec2(x, 0.0),
3607            });
3608            app.session.moved = Moved::Camera;
3609        }
3610
3611        fn select_the_filter(app: &mut App) {
3612            app.session.tool = crate::tools::resize_block::ResizeBlock::Selected {
3613                shape: ShapeId::Rect(blockworx_doc::fixtures::block_id(2)),
3614            }
3615            .into();
3616        }
3617
3618        /// §7.1's `view` row: undoing one restores the camera and touches no
3619        /// log. Asserted on the log itself, not on a flag — nothing is
3620        /// appended and the head does not move.
3621        #[test]
3622        fn a_view_undo_restores_the_camera_and_writes_nothing_to_the_log() {
3623            let ctx = egui::Context::default();
3624            let mut app = scene();
3625            let at = settle(&mut app, &ctx, Duration::from_secs(1));
3626            let opened = app.canvas.vantage();
3627            let head = app.session.doc.repo().rev();
3628            let commits = app.session.doc.repo().log().len();
3629
3630            look_at(&mut app, 40.0);
3631            let moved = app.canvas.vantage();
3632            let at = settle(&mut app, &ctx, at);
3633            assert_ne!(moved, opened, "precondition: the camera actually moved");
3634            assert!(
3635                app.session.has_step(Direction::Back),
3636                "precondition: the move became an entry",
3637            );
3638
3639            tick(&mut app, &ctx, at, Some(Action::Undo));
3640            assert_eq!(app.canvas.vantage(), opened, "the camera did not come back");
3641            assert_eq!(
3642                app.session.doc.repo().rev(),
3643                head,
3644                "a view undo authored a rev"
3645            );
3646            assert_eq!(
3647                app.session.doc.repo().log().len(),
3648                commits,
3649                "a view undo appended a commit",
3650            );
3651
3652            tick(&mut app, &ctx, at, Some(Action::Redo));
3653            assert_eq!(app.canvas.vantage(), moved, "redo did not mirror the undo");
3654            assert_eq!(
3655                app.session.doc.repo().rev(),
3656                head,
3657                "a view redo authored a rev"
3658            );
3659            assert_eq!(app.session.doc.repo().log().len(), commits);
3660        }
3661
3662        /// The interleaving: an edit between two camera moves keeps its own
3663        /// turn, and the presses come back newest first whichever kind they
3664        /// are.
3665        #[test]
3666        fn doc_and_view_entries_undo_in_the_order_they_were_made() {
3667            let ctx = egui::Context::default();
3668            let mut app = scene();
3669            let at = settle(&mut app, &ctx, Duration::from_secs(1));
3670            let opened = app.canvas.vantage();
3671
3672            look_at(&mut app, 40.0);
3673            let looked = app.canvas.vantage();
3674            let at = settle(&mut app, &ctx, at);
3675
3676            select_the_filter(&mut app);
3677            let before_edit = super::block_rect_of(&mut app, blockworx_doc::fixtures::block_id(2));
3678            tick(&mut app, &ctx, at, Some(Action::Nudge { dx: 1, dy: 0 }));
3679            let nudged = super::block_rect_of(&mut app, blockworx_doc::fixtures::block_id(2));
3680            assert_ne!(nudged, before_edit, "precondition: the nudge moved it");
3681            let edited = app.session.doc.repo().rev();
3682
3683            look_at(&mut app, 90.0);
3684            let at = settle(&mut app, &ctx, at);
3685
3686            // Newest first: the second camera move, then the edit, then the
3687            // first camera move.
3688            tick(&mut app, &ctx, at, Some(Action::Undo));
3689            assert_eq!(app.canvas.vantage(), looked, "the camera move was skipped");
3690            assert_eq!(
3691                app.session.doc.repo().rev(),
3692                edited,
3693                "it authored on the way past"
3694            );
3695
3696            tick(&mut app, &ctx, at, Some(Action::Undo));
3697            assert_eq!(
3698                super::block_rect_of(&mut app, blockworx_doc::fixtures::block_id(2)),
3699                before_edit,
3700                "the edit was not the next entry",
3701            );
3702            assert_ne!(
3703                app.session.doc.repo().rev(),
3704                edited,
3705                "the inverse is a rev of its own"
3706            );
3707
3708            tick(&mut app, &ctx, at, Some(Action::Undo));
3709            assert_eq!(
3710                app.canvas.vantage(),
3711                opened,
3712                "the first move never came back"
3713            );
3714        }
3715
3716        /// The guard the two stacks need: after a document undo the state fed
3717        /// back stands exactly where the trail does, so the next frame's
3718        /// feed adds nothing and the forward half survives.
3719        #[test]
3720        fn a_doc_undo_lands_the_stack_exactly_where_the_trail_stands() {
3721            let ctx = egui::Context::default();
3722            let mut app = scene();
3723            let at = settle(&mut app, &ctx, Duration::from_secs(1));
3724            select_the_filter(&mut app);
3725            tick(&mut app, &ctx, at, Some(Action::Nudge { dx: 1, dy: 0 }));
3726
3727            tick(&mut app, &ctx, at, Some(Action::Undo));
3728            assert_eq!(
3729                app.session.state().stood,
3730                crate::history::Stood::of(app.session.doc.trail()),
3731                "the state fed back does not stand where the trail does",
3732            );
3733            assert!(
3734                app.session.has_step(Direction::Forward),
3735                "the undo left nothing to redo",
3736            );
3737            // Idle frames, well past the coalescing window: nothing happened,
3738            // so nothing may be recorded and the future must survive.
3739            let at = settle(&mut app, &ctx, at);
3740            assert!(
3741                app.session.has_step(Direction::Forward),
3742                "an idle frame after an undo abandoned the future",
3743            );
3744            let _ = at;
3745        }
3746
3747        /// The walk is driven by [`Stood`](crate::history::Stood), which is
3748        /// now a rev rather than a depth — so it must still terminate, and
3749        /// land where it was aimed, over a trail with steps of every kind
3750        /// in it. Asserted against the documents rather than the count of
3751        /// steps taken: a walk that stops one short is the failure this
3752        /// guards.
3753        #[test]
3754        fn the_walk_lands_on_the_stood_it_was_aimed_at_over_a_mixed_trail() {
3755            use blockworx_doc::document::Document;
3756
3757            let ctx = egui::Context::default();
3758            let mut app = scene();
3759            let at = settle(&mut app, &ctx, Duration::from_secs(1));
3760            select_the_filter(&mut app);
3761            for _ in 0..3 {
3762                tick(&mut app, &ctx, at, Some(Action::Nudge { dx: 1, dy: 0 }));
3763            }
3764            let three_nudges = crate::history::Stood::of(app.session.doc.trail());
3765            let after_three: Document = app.session.doc.document().clone();
3766
3767            // A step each way, so the trail carries an undo record and a
3768            // redo record before the walk crosses them.
3769            tick(&mut app, &ctx, at, Some(Action::Undo));
3770            let after_one_back: Document = app.session.doc.document().clone();
3771            let one_back = crate::history::Stood::of(app.session.doc.trail());
3772            assert_ne!(one_back, three_nudges, "precondition: the undo moved");
3773            assert_ne!(after_one_back, after_three, "and moved the document");
3774            tick(&mut app, &ctx, at, Some(Action::Redo));
3775            assert_eq!(
3776                crate::history::Stood::of(app.session.doc.trail()),
3777                three_nudges,
3778                "precondition: a redo returns to the rev the undo left",
3779            );
3780
3781            let walked = app.session.walk_document(one_back);
3782            assert!(walked.is_some(), "the walk framed nothing it moved");
3783            assert_eq!(crate::history::Stood::of(app.session.doc.trail()), one_back);
3784            assert_eq!(
3785                app.session.doc.document(),
3786                &after_one_back,
3787                "the walk landed on the right rev with the wrong document",
3788            );
3789
3790            app.session.walk_document(three_nudges);
3791            assert_eq!(
3792                crate::history::Stood::of(app.session.doc.trail()),
3793                three_nudges
3794            );
3795            assert_eq!(
3796                app.session.doc.document(),
3797                &after_three,
3798                "the walk back up drifted"
3799            );
3800        }
3801
3802        /// §7.2 under the lens: undo serves `view` entries, and withholds the
3803        /// `doc` ones — they would author against a head nobody is looking at.
3804        #[test]
3805        fn under_the_lens_a_view_entry_undoes_and_a_doc_entry_does_not() {
3806            let ctx = egui::Context::default();
3807            let mut app = scene();
3808            let at = settle(&mut app, &ctx, Duration::from_secs(1));
3809            select_the_filter(&mut app);
3810            tick(&mut app, &ctx, at, Some(Action::Nudge { dx: 1, dy: 0 }));
3811            let at = settle(&mut app, &ctx, at);
3812            let nudged = super::block_rect_of(&mut app, blockworx_doc::fixtures::block_id(2));
3813
3814            let at_rev = app.session.doc.repo().rev();
3815            tick(&mut app, &ctx, at, Some(Action::ViewRev(at_rev)));
3816            assert!(
3817                matches!(
3818                    app.session.viewing(),
3819                    blockworx_store::doc::Viewing::Past(_)
3820                ),
3821                "precondition: a past rev is on the canvas",
3822            );
3823            let head = app.session.doc.repo().rev();
3824            let opened = app.canvas.vantage();
3825
3826            look_at(&mut app, 40.0);
3827            let at = settle(&mut app, &ctx, at);
3828            assert!(
3829                app.session
3830                    .available_commands(crate::edit::naming::InterfaceLock::Unlocked)
3831                    .contains(crate::tools::commands::CommandId::Undo),
3832                "the lens withheld an undo that costs the log nothing",
3833            );
3834            tick(&mut app, &ctx, at, Some(Action::Undo));
3835            assert_eq!(app.canvas.vantage(), opened, "a view undo was refused");
3836            assert_eq!(
3837                app.session.doc.repo().rev(),
3838                head,
3839                "a view undo authored a rev"
3840            );
3841
3842            // The next entry down is the edit, and it must not be served.
3843            assert!(
3844                !app.session
3845                    .available_commands(crate::edit::naming::InterfaceLock::Unlocked)
3846                    .contains(crate::tools::commands::CommandId::Undo),
3847                "the lens offered an undo that would author against the head",
3848            );
3849            tick(&mut app, &ctx, at, Some(Action::Undo));
3850            assert_eq!(
3851                app.session.doc.repo().rev(),
3852                head,
3853                "the chord wrote under the lens"
3854            );
3855            app.session.view_head();
3856            assert_eq!(
3857                super::block_rect_of(&mut app, blockworx_doc::fixtures::block_id(2)),
3858                nudged,
3859                "the document moved while nobody was allowed to write it",
3860            );
3861        }
3862    }
3863
3864    /// §7's other half, and the one this suite was written for: a step that
3865    /// changes the document lands with the change *in sight*. Driven over a
3866    /// real canvas pass, because "in sight" is measured against a viewport
3867    /// and a headless view has none.
3868    mod a_step_lands_in_sight {
3869        use super::{App, app_on, block_rect_of};
3870        use crate::history::{COALESCE, Direction};
3871        use crate::panels::painted::Chrome;
3872        use crate::path::{BlockPath, Scope};
3873        use crate::shape::ShapeId;
3874        use crate::tools::tool::Action;
3875        use crate::widget::test_fixtures as fx;
3876        use blockworx_doc::fixtures::block_id;
3877        use blockworx_geom::{Pos2, Rect, pos2, vec2};
3878        use core::time::Duration;
3879
3880        fn rect(x0: f32, y0: f32, x1: f32, y1: f32) -> Rect {
3881            Rect::from_min_max(pos2(x0, y0), pos2(x1, y1))
3882        }
3883
3884        fn chrome() -> Chrome {
3885            Chrome::new(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)))
3886        }
3887
3888        /// A wide sheet with a small block at each end, so a view framed on
3889        /// one leaves the other far outside it.
3890        fn two_ends() -> (App, Chrome) {
3891            let mut app = app_on(vec![
3892                fx::block_in(1, Scope::Root, rect(0.0, 0.0, 2400.0, 800.0)),
3893                fx::top(1),
3894                fx::block_in(
3895                    2,
3896                    Scope::Block(block_id(1)),
3897                    rect(40.0, 300.0, 120.0, 380.0),
3898                ),
3899                fx::block_in(
3900                    3,
3901                    Scope::Block(block_id(1)),
3902                    rect(2200.0, 300.0, 2280.0, 380.0),
3903                ),
3904            ]);
3905            let mut chrome = chrome();
3906            chrome.settle(|ui| {
3907                app.show_canvas(ui);
3908            });
3909            (app, chrome)
3910        }
3911
3912        /// One frame in the order `shell_frame` runs it: the state is read
3913        /// before anything, the canvas pass moves the camera, dispatch runs,
3914        /// and the frame is recorded last.
3915        /// One frame, and whatever the hand did in it. The action is
3916        /// dispatched through the *painting* context: a session has one, and
3917        /// a harness with two hides everything that is raised in a dispatch
3918        /// and read back while drawing.
3919        fn frame(app: &mut App, chrome: &mut Chrome, at: Duration, action: Option<Action>) {
3920            let ctx = chrome.ctx().clone();
3921            app.apply_framings(&ctx);
3922            let before = app.session.state();
3923            chrome.frame(|ui| {
3924                app.show_canvas(ui);
3925            });
3926            app.sync_camera(&ctx);
3927            if let Some(action) = action {
3928                app.dispatch_action(&ctx, action);
3929            }
3930            app.session.record_history(&before, at);
3931        }
3932
3933        /// Idle frames enough for a framing to arrive and for whatever just
3934        /// happened to settle into an entry of its own (§7.2's window).
3935        fn settle(app: &mut App, chrome: &mut Chrome, at: Duration) -> Duration {
3936            for _ in 0..24 {
3937                frame(app, chrome, at, None);
3938            }
3939            let at = at + COALESCE + COALESCE;
3940            for _ in 0..2 {
3941                frame(app, chrome, at, None);
3942            }
3943            at
3944        }
3945
3946        /// Put the camera on `block`'s neighbourhood, the way a hand would.
3947        fn look_at(app: &mut App, chrome: &mut Chrome, block: u32) {
3948            let at = block_rect_of(app, block_id(block));
3949            app.canvas.fit_to_rect_instant(at.expand(160.0));
3950            chrome.frame(|ui| {
3951                app.show_canvas(ui);
3952            });
3953        }
3954
3955        fn select(app: &mut App, block: u32) {
3956            app.session.tool = crate::tools::resize_block::ResizeBlock::Selected {
3957                shape: ShapeId::Rect(block_id(block)),
3958            }
3959            .into();
3960        }
3961
3962        fn in_sight(app: &mut App, block: u32) -> bool {
3963            let at = block_rect_of(app, block_id(block));
3964            app.canvas.visible_world_rect().intersects(at)
3965        }
3966
3967        /// Probe 1: an edit at one end, a settled pan to the other, an edit
3968        /// there. Walking all three back must leave each taken-back edit
3969        /// where it can be seen.
3970        #[test]
3971        fn each_undo_that_moves_the_document_lands_looking_at_what_it_took_back() {
3972            let (mut app, mut chrome) = two_ends();
3973            let at = settle(&mut app, &mut chrome, Duration::from_secs(1));
3974
3975            look_at(&mut app, &mut chrome, 2);
3976            let at = settle(&mut app, &mut chrome, at);
3977            select(&mut app, 2);
3978            frame(
3979                &mut app,
3980                &mut chrome,
3981                at,
3982                Some(Action::Nudge { dx: 1, dy: 0 }),
3983            );
3984            let near_edit = block_rect_of(&mut app, block_id(2));
3985            let at = settle(&mut app, &mut chrome, at);
3986
3987            look_at(&mut app, &mut chrome, 3);
3988            let at = settle(&mut app, &mut chrome, at);
3989            assert!(
3990                !in_sight(&mut app, 2),
3991                "precondition: the first edit is off screen from the second",
3992            );
3993            select(&mut app, 3);
3994            frame(
3995                &mut app,
3996                &mut chrome,
3997                at,
3998                Some(Action::Nudge { dx: 1, dy: 0 }),
3999            );
4000            let far_edit = block_rect_of(&mut app, block_id(3));
4001            let at = settle(&mut app, &mut chrome, at);
4002
4003            frame(&mut app, &mut chrome, at, Some(Action::Undo));
4004            let at = settle(&mut app, &mut chrome, at);
4005            assert_ne!(
4006                block_rect_of(&mut app, block_id(3)),
4007                far_edit,
4008                "precondition: the first press took back the far edit",
4009            );
4010            assert!(
4011                in_sight(&mut app, 3),
4012                "the far edit came back out of sight: {:?} is not in {:?}",
4013                block_rect_of(&mut app, block_id(3)),
4014                app.canvas.visible_world_rect(),
4015            );
4016
4017            // The pan between the two edits: a view step, which owes the log
4018            // nothing and simply walks the camera back.
4019            frame(&mut app, &mut chrome, at, Some(Action::Undo));
4020            let at = settle(&mut app, &mut chrome, at);
4021
4022            frame(&mut app, &mut chrome, at, Some(Action::Undo));
4023            settle(&mut app, &mut chrome, at);
4024            assert_ne!(
4025                block_rect_of(&mut app, block_id(2)),
4026                near_edit,
4027                "precondition: the third press took back the near edit",
4028            );
4029            assert!(
4030                in_sight(&mut app, 2),
4031                "the near edit came back out of sight: {:?} is not in {:?}",
4032                block_rect_of(&mut app, block_id(2)),
4033                app.canvas.visible_world_rect(),
4034            );
4035        }
4036
4037        /// A sheet whose left block opens a scope with something to edit
4038        /// inside it — two scopes over one document.
4039        fn two_scopes() -> (App, Chrome) {
4040            let mut app = app_on(vec![
4041                fx::block_in(1, Scope::Root, rect(0.0, 0.0, 2400.0, 800.0)),
4042                fx::top(1),
4043                fx::block_in(
4044                    2,
4045                    Scope::Block(block_id(1)),
4046                    rect(40.0, 300.0, 400.0, 660.0),
4047                ),
4048                fx::block_in(
4049                    4,
4050                    Scope::Block(block_id(2)),
4051                    rect(80.0, 340.0, 200.0, 460.0),
4052                ),
4053                fx::block_in(
4054                    3,
4055                    Scope::Block(block_id(1)),
4056                    rect(2200.0, 300.0, 2280.0, 380.0),
4057                ),
4058            ]);
4059            let mut chrome = chrome();
4060            chrome.settle(|ui| {
4061                app.show_canvas(ui);
4062            });
4063            (app, chrome)
4064        }
4065
4066        /// Probe 2: an edit inside a block's scope, then one a scope out.
4067        /// Taking the inner one back has to open the scope it happened in —
4068        /// otherwise the document moves in a drawing nobody is looking at.
4069        #[test]
4070        fn an_undo_that_crosses_a_scope_opens_the_scope_the_edit_happened_in() {
4071            let (mut app, mut chrome) = two_scopes();
4072            let inner = BlockPath::to_parent_of(app.session.doc.document(), block_id(4))
4073                .expect("block 4 has a path");
4074            let outer = app.session.path.clone();
4075            assert_ne!(
4076                inner, outer,
4077                "precondition: the two edits are in two scopes"
4078            );
4079
4080            app.session.path = inner.clone();
4081            app.session.after_navigate();
4082            let at = settle(&mut app, &mut chrome, Duration::from_secs(1));
4083            select(&mut app, 4);
4084            frame(
4085                &mut app,
4086                &mut chrome,
4087                at,
4088                Some(Action::Nudge { dx: 1, dy: 0 }),
4089            );
4090            let inner_edit = block_rect_of(&mut app, block_id(4));
4091            let at = settle(&mut app, &mut chrome, at);
4092
4093            app.session.path = outer.clone();
4094            app.session.after_navigate();
4095            let at = settle(&mut app, &mut chrome, at);
4096            select(&mut app, 3);
4097            frame(
4098                &mut app,
4099                &mut chrome,
4100                at,
4101                Some(Action::Nudge { dx: 1, dy: 0 }),
4102            );
4103            let at = settle(&mut app, &mut chrome, at);
4104
4105            // Back through the outer edit, back through the scope change,
4106            // and then the inner edit itself.
4107            let mut at = at;
4108            for _ in 0..3 {
4109                frame(&mut app, &mut chrome, at, Some(Action::Undo));
4110                at = settle(&mut app, &mut chrome, at);
4111            }
4112            assert_eq!(
4113                app.session.path, inner,
4114                "the undo left the wrong scope open"
4115            );
4116            assert_ne!(
4117                block_rect_of(&mut app, block_id(4)),
4118                inner_edit,
4119                "precondition: the inner edit was the one taken back",
4120            );
4121            assert!(
4122                in_sight(&mut app, 4),
4123                "the inner edit came back out of sight",
4124            );
4125        }
4126
4127        /// R57: putting a past rev on the canvas opens the level that rev
4128        /// worked on. A lens showing a drawing the step never touched is a
4129        /// confusion of its own, and the scope is already in the commit's own
4130        /// ops — the pick has no excuse not to use it.
4131        #[test]
4132        fn a_rev_pick_opens_the_scope_that_rev_worked_in() {
4133            let (mut app, mut chrome) = two_scopes();
4134            let inner = BlockPath::to_parent_of(app.session.doc.document(), block_id(4))
4135                .expect("block 4 has a path");
4136            let outer = app.session.path.clone();
4137            assert_ne!(
4138                inner, outer,
4139                "precondition: the edit's level is not the one the reader stands on",
4140            );
4141
4142            // An edit two scopes in, made from the level it happened on, then
4143            // the reader comes back out to browse.
4144            app.session.path = inner.clone();
4145            app.session.after_navigate();
4146            let at = settle(&mut app, &mut chrome, Duration::from_secs(1));
4147            select(&mut app, 4);
4148            frame(
4149                &mut app,
4150                &mut chrome,
4151                at,
4152                Some(Action::Nudge { dx: 1, dy: 0 }),
4153            );
4154            let at = settle(&mut app, &mut chrome, at);
4155            let nudged = app.session.doc.repo().rev();
4156            app.session.path = outer.clone();
4157            app.session.after_navigate();
4158            let at = settle(&mut app, &mut chrome, at);
4159            assert_eq!(
4160                app.session.path, outer,
4161                "precondition: the reader is back outside"
4162            );
4163
4164            frame(&mut app, &mut chrome, at, Some(Action::ViewRev(nudged)));
4165            assert_eq!(
4166                app.session.viewing(),
4167                blockworx_store::doc::Viewing::Past(nudged),
4168                "precondition: the lens is on the rev that was picked",
4169            );
4170            assert_eq!(
4171                app.session.path, inner,
4172                "the pick left the reader on a level that rev did not touch",
4173            );
4174            assert!(
4175                in_sight(&mut app, 4),
4176                "the pick opened the level but not onto what changed",
4177            );
4178        }
4179
4180        /// Probe 3 / the coordinator's case (a): an edit dispatched at
4181        /// something the camera is nowhere near — the palette's door. The
4182        /// state the stack pins holds the camera the user was actually at,
4183        /// which never saw the edit, so restoring it alone is not enough.
4184        #[test]
4185        fn an_edit_made_off_screen_is_brought_into_sight_when_it_is_taken_back() {
4186            let (mut app, mut chrome) = two_ends();
4187            look_at(&mut app, &mut chrome, 2);
4188            let at = settle(&mut app, &mut chrome, Duration::from_secs(1));
4189
4190            select(&mut app, 3);
4191            assert!(
4192                !in_sight(&mut app, 3),
4193                "precondition: the edit is aimed off screen",
4194            );
4195            let before = block_rect_of(&mut app, block_id(3));
4196            frame(
4197                &mut app,
4198                &mut chrome,
4199                at,
4200                Some(Action::Nudge { dx: 1, dy: 0 }),
4201            );
4202            let at = settle(&mut app, &mut chrome, at);
4203            assert_ne!(
4204                block_rect_of(&mut app, block_id(3)),
4205                before,
4206                "precondition: the off-screen edit landed",
4207            );
4208
4209            frame(&mut app, &mut chrome, at, Some(Action::Undo));
4210            settle(&mut app, &mut chrome, at);
4211            assert_eq!(
4212                block_rect_of(&mut app, block_id(3)),
4213                before,
4214                "precondition: the press took the edit back",
4215            );
4216            assert!(
4217                in_sight(&mut app, 3),
4218                "the undo moved a block nobody could see: {:?} is not in {:?}",
4219                block_rect_of(&mut app, block_id(3)),
4220                app.canvas.visible_world_rect(),
4221            );
4222        }
4223
4224        /// Probe 4: redo is the same contract, mirrored — and a step that
4225        /// framed what it changed must leave the future intact, or the
4226        /// framing has quietly cost the user their redo.
4227        #[test]
4228        fn a_redo_lands_looking_at_what_it_puts_back() {
4229            let (mut app, mut chrome) = two_ends();
4230            look_at(&mut app, &mut chrome, 2);
4231            let at = settle(&mut app, &mut chrome, Duration::from_secs(1));
4232
4233            select(&mut app, 3);
4234            let before = block_rect_of(&mut app, block_id(3));
4235            frame(
4236                &mut app,
4237                &mut chrome,
4238                at,
4239                Some(Action::Nudge { dx: 1, dy: 0 }),
4240            );
4241            let nudged = block_rect_of(&mut app, block_id(3));
4242            assert_ne!(nudged, before, "precondition: the edit landed");
4243            let at = settle(&mut app, &mut chrome, at);
4244
4245            frame(&mut app, &mut chrome, at, Some(Action::Undo));
4246            let at = settle(&mut app, &mut chrome, at);
4247            assert_eq!(
4248                block_rect_of(&mut app, block_id(3)),
4249                before,
4250                "precondition: the press took the edit back",
4251            );
4252            assert!(
4253                app.session.has_step(Direction::Forward),
4254                "framing what the undo changed abandoned the redo",
4255            );
4256
4257            frame(&mut app, &mut chrome, at, Some(Action::Redo));
4258            settle(&mut app, &mut chrome, at);
4259            assert_eq!(
4260                block_rect_of(&mut app, block_id(3)),
4261                nudged,
4262                "the redo did not put the edit back",
4263            );
4264            assert!(
4265                in_sight(&mut app, 3),
4266                "the redo put a block back where nobody could see it",
4267            );
4268        }
4269
4270        /// Probe 3: the edit pin. An edit punctuates the stack either side of
4271        /// itself, and the camera those two points carry has to be the one
4272        /// that held *at the edit* — not a snapshot from before whatever the
4273        /// hand did in the coalescing window since.
4274        #[test]
4275        fn the_state_an_edit_pins_carries_the_camera_the_edit_was_made_at() {
4276            let (mut app, mut chrome) = two_ends();
4277            look_at(&mut app, &mut chrome, 2);
4278            let at = settle(&mut app, &mut chrome, Duration::from_secs(1));
4279
4280            // The hand pans to the far block and edits it straight away —
4281            // inside §7.2's window, so the pan never settled into an entry
4282            // of its own.
4283            look_at(&mut app, &mut chrome, 3);
4284            let looking = app.canvas.vantage();
4285            select(&mut app, 3);
4286            frame(
4287                &mut app,
4288                &mut chrome,
4289                at,
4290                Some(Action::Nudge { dx: 1, dy: 0 }),
4291            );
4292            let pinned = app
4293                .session
4294                .undo_stack
4295                .peek(Direction::Back, &app.session.state())
4296                .expect("the edit is a step to take back");
4297            assert_eq!(
4298                pinned.camera, looking,
4299                "the edit pinned a camera the hand had already left",
4300            );
4301            assert_ne!(
4302                pinned.stood,
4303                app.session.state().stood,
4304                "precondition: the step being peeked is the edit itself",
4305            );
4306        }
4307
4308        /// R55's other half: the step also *rings* what it took back, so a
4309        /// camera that arrived somewhere new says why it did. Proven through
4310        /// the real canvas pass, since a ring is a thing drawn: the run of
4311        /// outlines the frame paints gains one round the changed block, and
4312        /// a frame with no step behind it paints none.
4313        #[test]
4314        fn a_step_rings_what_it_took_back() {
4315            let (mut app, mut chrome) = two_ends();
4316            look_at(&mut app, &mut chrome, 2);
4317            let at = settle(&mut app, &mut chrome, Duration::from_secs(1));
4318
4319            let rings = |app: &mut App, chrome: &Chrome, world: Rect| -> Vec<Rect> {
4320                let wanted = on_screen(app, world.expand(crate::spotlight::CLEAR));
4321                chrome
4322                    .outlines()
4323                    .iter()
4324                    .filter(|drawn| near(**drawn, wanted))
4325                    .copied()
4326                    .collect()
4327            };
4328            let unmoved = block_rect_of(&mut app, block_id(3));
4329            assert!(
4330                rings(&mut app, &chrome, unmoved).is_empty(),
4331                "a frame with no step behind it drew a ring",
4332            );
4333
4334            select(&mut app, 3);
4335            assert!(
4336                !in_sight(&mut app, 3),
4337                "precondition: the edit is aimed off screen, so the ring has work to do",
4338            );
4339            frame(
4340                &mut app,
4341                &mut chrome,
4342                at,
4343                Some(Action::Nudge { dx: 1, dy: 0 }),
4344            );
4345            let at = settle(&mut app, &mut chrome, at);
4346            // The inverse is written against the document as it stands after
4347            // the nudge, so this — not the rect the undo restores — is what
4348            // the ring frames.
4349            let nudged = block_rect_of(&mut app, block_id(3));
4350            look_at(&mut app, &mut chrome, 2);
4351
4352            frame(&mut app, &mut chrome, at, Some(Action::Undo));
4353            // The undo dispatches after the frame paints, so the ring it
4354            // raised lands on the next one.
4355            frame(&mut app, &mut chrome, at, None);
4356            let drawn = rings(&mut app, &chrome, nudged);
4357            assert_eq!(
4358                drawn.len(),
4359                1,
4360                "the undo drew {} rings round what it took back",
4361                drawn.len(),
4362            );
4363        }
4364
4365        /// §12.7 of `docs/log-vs-snapshot.md`: a delete removes, so the rev
4366        /// that took a block away holds no footprint for it — the ring is
4367        /// read from the document the step departed. Proven through
4368        /// `view_rev`, the path a picked rev actually travels.
4369        #[test]
4370        fn viewing_a_delete_rings_where_the_block_stood() {
4371            let (mut app, mut chrome) = two_ends();
4372            look_at(&mut app, &mut chrome, 3);
4373            let at = settle(&mut app, &mut chrome, Duration::from_secs(1));
4374            let stood = block_rect_of(&mut app, block_id(3));
4375
4376            select(&mut app, 3);
4377            frame(
4378                &mut app,
4379                &mut chrome,
4380                at,
4381                Some(Action::Delete(crate::tools::tool::Deletable::Shape(
4382                    ShapeId::Rect(block_id(3)),
4383                ))),
4384            );
4385            let at = settle(&mut app, &mut chrome, at);
4386            let deleted = app.session.doc.repo().rev();
4387            assert!(
4388                app.session
4389                    .doc
4390                    .repo()
4391                    .document()
4392                    .block(&block_id(3))
4393                    .is_none(),
4394                "precondition: the delete removed the block rather than hiding it",
4395            );
4396
4397            app.session.view_rev(deleted);
4398            frame(&mut app, &mut chrome, at, None);
4399
4400            let wanted = on_screen(&app, stood.expand(crate::spotlight::CLEAR));
4401            assert!(
4402                chrome.outlines().iter().any(|drawn| near(*drawn, wanted)),
4403                "no ring was drawn where the deleted block stood",
4404            );
4405        }
4406
4407        /// Where a world rect lands on screen under the camera as it stands.
4408        fn on_screen(app: &App, world: Rect) -> Rect {
4409            let origin = app.canvas.viewport().min;
4410            Rect::from_min_max(
4411                app.canvas.world_to_screen(origin, world.min),
4412                app.canvas.world_to_screen(origin, world.max),
4413            )
4414        }
4415
4416        /// Two rects the same to within a rounding.
4417        fn near(a: Rect, b: Rect) -> bool {
4418            a.min.distance(b.min) < 0.5 && a.max.distance(b.max) < 0.5
4419        }
4420
4421        /// The other half of the contract, and the one that keeps it from
4422        /// becoming a nuisance: a step whose change is already on screen
4423        /// moves the camera not one pixel. Layer 1 wins where it can.
4424        #[test]
4425        fn an_undo_of_something_in_plain_sight_leaves_the_camera_alone() {
4426            let (mut app, mut chrome) = two_ends();
4427            look_at(&mut app, &mut chrome, 2);
4428            let at = settle(&mut app, &mut chrome, Duration::from_secs(1));
4429
4430            select(&mut app, 2);
4431            frame(
4432                &mut app,
4433                &mut chrome,
4434                at,
4435                Some(Action::Nudge { dx: 1, dy: 0 }),
4436            );
4437            let at = settle(&mut app, &mut chrome, at);
4438            assert!(
4439                in_sight(&mut app, 2),
4440                "precondition: the edit is on screen already",
4441            );
4442            let camera = app.canvas.vantage();
4443
4444            frame(&mut app, &mut chrome, at, Some(Action::Undo));
4445            settle(&mut app, &mut chrome, at);
4446            assert_eq!(
4447                app.canvas.vantage(),
4448                camera,
4449                "an undo of something in plain sight jumped the camera",
4450            );
4451        }
4452
4453        /// The coordinator's case (b) / F6: a session that only *reopened*
4454        /// the document has a reconstructed stack whose points carry no
4455        /// camera or scope of their own — §6.2 says viewing state does not
4456        /// survive a reload. The undo still has to land in sight.
4457        #[test]
4458        fn a_reopened_document_undoes_into_sight_of_the_edit() {
4459            use crate::path::Scope;
4460            use blockworx_doc::{commit::Commit, opcode::OpCodes};
4461
4462            let repo = blockworx_doc::repo::Repo::folding(&[Commit::new(
4463                "Built a scene".into(),
4464                vec![
4465                    fx::block_in(1, Scope::Root, rect(0.0, 0.0, 2400.0, 800.0)),
4466                    fx::top(1),
4467                    fx::block_in(
4468                        2,
4469                        Scope::Block(block_id(1)),
4470                        rect(40.0, 300.0, 400.0, 660.0),
4471                    ),
4472                    fx::block_in(
4473                        4,
4474                        Scope::Block(block_id(2)),
4475                        rect(80.0, 340.0, 200.0, 460.0),
4476                    ),
4477                ],
4478            )])
4479            .expect("the scene folds");
4480            let mut doc = blockworx_store::doc::Doc::scratch(repo);
4481            doc.submit(
4482                Commit::new(
4483                    "Nudged it".into(),
4484                    vec![OpCodes::Block(
4485                        block_id(4),
4486                        blockworx_doc::opcode::Crud::Update(
4487                            blockworx_doc::block_model::BlockUpdate::Rect(
4488                                blockworx_doc::geometry::GridRect {
4489                                    top_left: blockworx_doc::geometry::GridPoint { x: 8, y: 24 },
4490                                    size: blockworx_doc::geometry::GridSize { w: 8, h: 8 },
4491                                },
4492                            ),
4493                        ),
4494                    )],
4495                ),
4496                &blockworx_store::record::Identity::new("Ada Lovelace"),
4497            )
4498            .expect("the nudge folds");
4499
4500            let mut app = App::new(super::super::AppConfig::default());
4501            app.session.adopt(doc);
4502            app.session.path = BlockPath::opening(app.session.doc.document());
4503            let mut chrome = chrome();
4504            chrome.settle(|ui| {
4505                app.show_canvas(ui);
4506            });
4507            assert_eq!(
4508                app.session.path,
4509                BlockPath::opening(app.session.doc.document()),
4510                "precondition: the session opens where the document says",
4511            );
4512            assert!(
4513                app.session.has_step(Direction::Back),
4514                "precondition: the reopened trail offers a step",
4515            );
4516            let at = settle(&mut app, &mut chrome, Duration::from_secs(1));
4517
4518            frame(&mut app, &mut chrome, at, Some(Action::Undo));
4519            settle(&mut app, &mut chrome, at);
4520            assert_eq!(
4521                app.session.path,
4522                BlockPath::to_parent_of(app.session.doc.document(), block_id(4))
4523                    .expect("block 4 has a path"),
4524                "the reopened undo left the wrong scope open",
4525            );
4526            assert!(
4527                in_sight(&mut app, 4),
4528                "the reopened undo moved a block nobody could see",
4529            );
4530        }
4531    }
4532
4533    /// The world→screen mapping a frame painted under, read off the view.
4534    #[derive(Clone, Copy, Debug)]
4535    struct Camera {
4536        origin: Pos2,
4537        zoom: f32,
4538    }
4539
4540    impl Camera {
4541        fn of(view: &crate::canvas::View) -> Camera {
4542            Camera {
4543                origin: view.viewport().min + view.translation,
4544                zoom: view.zoom.get(),
4545            }
4546        }
4547
4548        fn place(self, world: Rect) -> Rect {
4549            Rect::from_min_max(
4550                self.origin + world.min.to_vec2() * self.zoom,
4551                self.origin + world.max.to_vec2() * self.zoom,
4552            )
4553        }
4554
4555        fn unplace(self, screen: Rect) -> Rect {
4556            Rect::from_min_max(
4557                ((screen.min - self.origin) / self.zoom).to_pos2(),
4558                ((screen.max - self.origin) / self.zoom).to_pos2(),
4559            )
4560        }
4561    }
4562
4563    /// G5: a scope change reframes the canvas *in* the frame it lands on.
4564    /// The fit needs a render to measure, and G2 owed it to the next frame —
4565    /// so the new level painted once under the camera of the level just
4566    /// left, then jumped. Judged through real frames, on what the first one
4567    /// after the navigation actually painted: it must already be what the
4568    /// settled frames paint, and the precondition proves the old camera
4569    /// would have put that ink somewhere else entirely.
4570    #[test]
4571    fn a_scope_change_paints_its_first_frame_already_fitted() {
4572        use crate::panels::painted::Chrome;
4573        use crate::path::Scope;
4574        use crate::widget::test_fixtures as fx;
4575        use blockworx_doc::fixtures::block_id;
4576
4577        let at = |x: f32, w: f32| Rect::from_min_size(pos2(x, 0.0), vec2(w, w));
4578        let mut app = app_on(vec![
4579            fx::block(1, 0.0),
4580            // The level the editor opens on: wide, so its fit is a small zoom.
4581            fx::block_in(2, Scope::Block(block_id(1)), at(0.0, 40.0)),
4582            fx::block_in(3, Scope::Block(block_id(1)), at(600.0, 40.0)),
4583            // The level below it: one small block, so its fit is a large one.
4584            fx::block_in(4, Scope::Block(block_id(2)), at(0.0, 20.0)),
4585            fx::top(1),
4586        ]);
4587        let ctx = egui::Context::default();
4588        let mut chrome = Chrome::new(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)));
4589        chrome.settle(|ui| {
4590            app.show_canvas(ui);
4591        });
4592        let left_behind = Camera::of(&app.canvas);
4593
4594        app.dispatch_action(&ctx, Action::ExpandBlock(block_id(2)));
4595        chrome.frame(|ui| {
4596            app.show_canvas(ui);
4597        });
4598        let first: Vec<Rect> = chrome.outlines().to_vec();
4599
4600        let landed = Camera::of(&app.canvas);
4601        chrome.settle(|ui| {
4602            app.show_canvas(ui);
4603        });
4604        let settled: Vec<Rect> = chrome.outlines().to_vec();
4605        assert!(
4606            !settled.is_empty(),
4607            "the level below drew nothing, so there is no framing to judge",
4608        );
4609
4610        // The same ink under the camera the navigation left behind: if that
4611        // lands where the fitted camera does, this test proves nothing.
4612        let ink = settled
4613            .iter()
4614            .copied()
4615            .reduce(Rect::union)
4616            .expect("the settled frame painted something");
4617        let stale = left_behind.place(landed.unplace(ink));
4618        assert!(
4619            stale.center().distance(ink.center()) > 20.0
4620                || (stale.width() - ink.width()).abs() > 20.0,
4621            "precondition: the scope change must move the camera far enough to see \
4622             ({stale:?} vs {ink:?})",
4623        );
4624
4625        assert_eq!(
4626            first, settled,
4627            "the first frame after the scope change painted under a camera that is \
4628             not the new level's fit — the flash",
4629        );
4630    }
4631
4632    /// G7: a block holding blocks of its own is drawn with a second line
4633    /// inside its outline — the classical contains-a-sheet notation — and a
4634    /// leaf is drawn with one. Judged on what the real canvas painted, in
4635    /// screen space, so a frame drawn some other way shows up here.
4636    #[test]
4637    fn a_block_holding_blocks_paints_a_second_border_inside_its_outline() {
4638        use crate::panels::painted::Chrome;
4639        use crate::path::Scope;
4640        use crate::widget::test_fixtures as fx;
4641        use blockworx_doc::fixtures::block_id;
4642        use blockworx_editor::render::SHEET_INSET;
4643
4644        let body = |x: f32| Rect::from_min_max(pos2(x, 0.0), pos2(x + 60.0, 60.0));
4645        let mut app = app_on(vec![
4646            fx::block_in(1, Scope::Root, body(0.0)),
4647            fx::block_in(2, Scope::Root, body(120.0)),
4648            // What makes block 1 a scope and leaves block 2 a leaf.
4649            fx::block_in(3, Scope::Block(block_id(1)), body(0.0)),
4650        ]);
4651        let mut chrome = Chrome::new(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)));
4652        chrome.settle(|ui| {
4653            app.show_canvas(ui);
4654        });
4655        app.canvas
4656            .fit_to_rect_instant(Rect::from_min_max(pos2(-20.0, -20.0), pos2(200.0, 80.0)));
4657        chrome.settle(|ui| {
4658            app.show_canvas(ui);
4659        });
4660
4661        let camera = Camera::of(&app.canvas);
4662        let structured = block_rect_of(&mut app, block_id(1));
4663        let leaf = block_rect_of(&mut app, block_id(2));
4664        let inset = |world: Rect| camera.place(world.shrink(SHEET_INSET.get()));
4665        let painted = |chrome: &Chrome, want: Rect| {
4666            chrome.outlines().iter().any(|drawn| {
4667                drawn.min.distance(want.min) < 2.0 && drawn.max.distance(want.max) < 2.0
4668            })
4669        };
4670        assert!(
4671            camera.place(structured).min.distance(inset(structured).min) > 4.0,
4672            "precondition: the zoom must separate the two lines by more than the \
4673             tolerance, or this test cannot tell them apart",
4674        );
4675
4676        assert!(
4677            painted(&chrome, camera.place(structured)),
4678            "the structured block painted no outline at {:?}: {:?}",
4679            camera.place(structured),
4680            chrome.outlines(),
4681        );
4682        assert!(
4683            painted(&chrome, inset(structured)),
4684            "the structured block painted no inset line at {:?}: {:?}",
4685            inset(structured),
4686            chrome.outlines(),
4687        );
4688        assert!(
4689            painted(&chrome, camera.place(leaf)),
4690            "the leaf block painted no outline at {:?}: {:?}",
4691            camera.place(leaf),
4692            chrome.outlines(),
4693        );
4694        assert!(
4695            !painted(&chrome, inset(leaf)),
4696            "the leaf block painted a second line at {:?}, which is the structured \
4697             block's notation: {:?}",
4698            inset(leaf),
4699            chrome.outlines(),
4700        );
4701    }
4702
4703    /// The preview draws through the same shape render as the resting block,
4704    /// so a structured block keeps both lines while it is being dragged.
4705    #[test]
4706    fn a_dragged_structured_block_previews_with_both_its_lines() {
4707        use crate::grid::GRID_SIZE;
4708        use crate::panels::painted::Chrome;
4709        use crate::path::Scope;
4710        use crate::tools::{MoveBlock, tool::Tool};
4711        use crate::widget::test_fixtures as fx;
4712        use blockworx_doc::fixtures::block_id;
4713        use blockworx_editor::render::SHEET_INSET;
4714
4715        let ctx = egui::Context::default();
4716        let body = Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 60.0));
4717        let mut app = app_on(vec![
4718            fx::block_in(1, Scope::Root, body),
4719            fx::block_in(2, Scope::Block(block_id(1)), body),
4720        ]);
4721        // The canvas pass hands its action back for the app to dispatch, and
4722        // a body drag *is* one — a frame that drops it never leaves `Select`.
4723        let show = |app: &mut App, ui: &mut egui::Ui| {
4724            if let Some(action) = app.show_canvas(ui) {
4725                app.dispatch_action(&ctx, action);
4726            }
4727        };
4728        let mut chrome = Chrome::new(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)));
4729        chrome.settle(|ui| show(&mut app, ui));
4730        app.canvas
4731            .fit_to_rect_instant(Rect::from_min_max(pos2(-40.0, -40.0), pos2(200.0, 120.0)));
4732        chrome.settle(|ui| show(&mut app, ui));
4733
4734        let camera = Camera::of(&app.canvas);
4735        let resting = block_rect_of(&mut app, block_id(1));
4736        // A whole number of cells, so the preview lands exactly where the grid
4737        // snap puts it.
4738        let travel = vec2(4.0 * GRID_SIZE, 0.0);
4739        let grab = camera.place(resting).center();
4740        chrome.press_at(grab, |ui| show(&mut app, ui));
4741        chrome.drag_to(grab + vec2(12.0, 0.0), |ui| show(&mut app, ui));
4742        chrome.drag_to(grab + travel * camera.zoom, |ui| show(&mut app, ui));
4743
4744        assert!(
4745            matches!(
4746                app.session.tool,
4747                Tool::MoveBlock(MoveBlock::Dragging { .. })
4748            ),
4749            "precondition: the grab must put the block into a drag, got {:?}",
4750            {
4751                use crate::tools::tool::ToolTrait as _;
4752                app.session.tool.name()
4753            },
4754        );
4755        let previewed = resting.translate(travel);
4756        let painted = |want: Rect| {
4757            chrome.outlines().iter().any(|drawn| {
4758                drawn.min.distance(want.min) < 2.0 && drawn.max.distance(want.max) < 2.0
4759            })
4760        };
4761        assert!(
4762            painted(camera.place(previewed)),
4763            "mid-drag, the block previewed no outline at {:?}: {:?}",
4764            camera.place(previewed),
4765            chrome.outlines(),
4766        );
4767        assert!(
4768            painted(camera.place(previewed.shrink(SHEET_INSET.get()))),
4769            "mid-drag, the block previewed no inset line at {:?}: {:?}",
4770            camera.place(previewed.shrink(SHEET_INSET.get())),
4771            chrome.outlines(),
4772        );
4773    }
4774
4775    fn block_rect_of(app: &mut App, block: blockworx_doc::id::BlockId) -> Rect {
4776        app.session
4777            .drawing()
4778            .shape(crate::shape::ShapeId::Rect(block))
4779            .expect("the block is in this scope")
4780            .gui_rect()
4781    }
4782
4783    /// "Go up" pops the block path, and that is all it does. At the
4784    /// document root there is nowhere above, so a writable session gets
4785    /// nothing at all — and the registry withholds the command there, so
4786    /// the toolbar's Up button is drawn dead rather than doing something
4787    /// else instead (docs/ui-issues-2.md, item 8).
4788    #[test]
4789    fn going_up_from_the_root_does_nothing_and_is_not_offered() {
4790        use crate::widget::test_fixtures as fx;
4791        let ctx = egui::Context::default();
4792        let mut app = app_on(vec![fx::block(1, 0.0), fx::top(1)]);
4793        let named_top = app.session.doc.document().title_block().top;
4794        assert_eq!(
4795            named_top,
4796            blockworx_doc::fixtures::block_id(1),
4797            "the fixture names its own top, so a changed one means this wrote it",
4798        );
4799        assert_eq!(
4800            app.session.may_write(),
4801            blockworx_store::doc::Writability::Writable,
4802            "precondition: nothing but the root is refusing here",
4803        );
4804        assert!(
4805            !app.session.path.segments().is_empty(),
4806            "an editor opens inside the top block",
4807        );
4808        assert!(
4809            app.session
4810                .available_commands(crate::edit::naming::InterfaceLock::Unlocked)
4811                .contains(crate::tools::commands::CommandId::GoUp),
4812            "precondition: inside a block there is a level to rise to",
4813        );
4814
4815        app.dispatch_action(&ctx, Action::GoUp);
4816        assert!(
4817            app.session.path.segments().is_empty(),
4818            "the first go-up pops to the root",
4819        );
4820
4821        let before = app.session.doc.repo().rev();
4822        app.dispatch_action(&ctx, Action::GoUp);
4823        assert!(
4824            app.session.path.segments().is_empty(),
4825            "and the root is where it stops"
4826        );
4827        assert_eq!(
4828            app.session.doc.document().title_block().top,
4829            named_top,
4830            "a go-up at the root wrapped the document",
4831        );
4832        assert_eq!(
4833            app.session.doc.repo().rev(),
4834            before,
4835            "and something reached the log"
4836        );
4837        assert!(
4838            !app.session
4839                .available_commands(crate::edit::naming::InterfaceLock::Unlocked)
4840                .contains(crate::tools::commands::CommandId::GoUp),
4841            "the root offers a way up, so the toolbar draws its button live",
4842        );
4843    }
4844
4845    /// An arrow key moves the selected shape by one cell. The delta rides on
4846    /// the action; the *selection* comes from the tool, which is why this
4847    /// cannot be driven through the document-scoped dispatch.
4848    #[test]
4849    fn an_arrow_key_nudges_the_selected_shape_by_a_cell() {
4850        use crate::widget::test_fixtures as fx;
4851        let block = blockworx_doc::fixtures::block_id(1);
4852        let mut app = app_on(vec![fx::block(1, 0.0), fx::block(2, 120.0)]);
4853        app.session.tool = crate::tools::resize_block::ResizeBlock::Selected {
4854            shape: crate::shape::ShapeId::Rect(block),
4855        }
4856        .into();
4857        let before = block_rect_of(&mut app, block);
4858
4859        app.dispatch_action(&egui::Context::default(), Action::Nudge { dx: 1, dy: 0 });
4860
4861        let after = block_rect_of(&mut app, block);
4862        assert_eq!(
4863            after.min.x - before.min.x,
4864            crate::grid::GRID_SIZE,
4865            "the nudge moved the block {} px, not one cell",
4866            after.min.x - before.min.x,
4867        );
4868        assert_eq!(
4869            after.min.y, before.min.y,
4870            "a horizontal nudge moved it down"
4871        );
4872    }
4873
4874    /// A pin selection nudges by whole slots instead, and only vertically —
4875    /// a separate emitter (`nudge_pins`) reached through the same arm.
4876    #[test]
4877    fn an_arrow_key_nudges_a_pin_selection_by_a_slot() {
4878        use crate::widget::test_fixtures as fx;
4879        let pin = blockworx_doc::fixtures::pin_id(3);
4880        let mut app = app_on(vec![
4881            fx::block_in(
4882                1,
4883                crate::path::Scope::Root,
4884                Rect::from_min_max(pos2(0.0, 0.0), pos2(90.0, 300.0)),
4885            ),
4886            fx::pin(3, 1, crate::shape::pin::PinSide::West, 0),
4887        ]);
4888        app.session.tool = crate::tools::MultiPinSelect::Selected { pins: vec![pin] }.into();
4889        let slot_of = |app: &App| {
4890            app.session
4891                .doc
4892                .repo()
4893                .document()
4894                .pin(&pin)
4895                .expect("the pin is in the document")
4896                .slot
4897        };
4898        let before = slot_of(&app);
4899
4900        app.dispatch_action(&egui::Context::default(), Action::Nudge { dx: 0, dy: 1 });
4901
4902        let after = slot_of(&app);
4903        assert_ne!(
4904            after.offset, before.offset,
4905            "the pin kept slot {before:?} through a vertical nudge",
4906        );
4907    }
4908
4909    // ── The clipboard arms ───────────────────────────────────────────────────
4910    //
4911    // These looked blocked on the OS clipboard and are not: `Action::Cut` and
4912    // `Action::CutPins` write the document *before* handing the payload to
4913    // `ctx.copy_text`, and `Action::Paste` takes the text as an argument. So
4914    // the whole round trip runs against a bare `egui::Context`, whose
4915    // `CopyText` command is the clipboard as far as the app is concerned.
4916
4917    /// What the app just put on the clipboard.
4918    fn copied(ctx: &egui::Context) -> Option<String> {
4919        ctx.output(|out| {
4920            out.commands.iter().rev().find_map(|command| match command {
4921                egui::OutputCommand::CopyText(text) => Some(text.clone()),
4922                _ => None,
4923            })
4924        })
4925    }
4926
4927    fn holds_block(app: &App, block: blockworx_doc::id::BlockId) -> bool {
4928        app.session.doc.repo().document().block(&block).is_some()
4929    }
4930
4931    fn live_blocks(app: &App) -> usize {
4932        app.session.doc.repo().document().blocks().count()
4933    }
4934
4935    fn live_routes(app: &App) -> usize {
4936        app.session.doc.repo().document().routes().count()
4937    }
4938
4939    /// Cut writes the document and *then* copies, so the two halves are one
4940    /// gesture. Pasting the payload back completes the move: the same ids come
4941    /// back rather than a duplicate appearing, which is what makes the pair
4942    /// one displacement in the log and one thing for an undo to address.
4943    #[test]
4944    fn cutting_a_block_removes_it_and_pasting_moves_it_back() {
4945        use crate::widget::test_fixtures as fx;
4946        let ctx = egui::Context::default();
4947        let block = blockworx_doc::fixtures::block_id(2);
4948        let mut app = app_on(vec![fx::block(1, 0.0), fx::block(2, 120.0)]);
4949        assert!(
4950            holds_block(&app, block),
4951            "precondition: there is a block to cut"
4952        );
4953        let before = live_blocks(&app);
4954
4955        app.dispatch_action(&ctx, Action::Cut(vec![crate::shape::ShapeId::Rect(block)]));
4956
4957        assert!(!holds_block(&app, block), "the cut left the block in place");
4958        let payload = copied(&ctx).expect("a cut puts its snapshot on the clipboard");
4959
4960        app.dispatch_action(&ctx, Action::Paste(payload));
4961
4962        assert!(
4963            holds_block(&app, block),
4964            "pasting a cut did not bring it back"
4965        );
4966        assert_eq!(
4967            live_blocks(&app),
4968            before,
4969            "pasting a cut duplicated it instead of moving it",
4970        );
4971    }
4972
4973    /// Paste with the original still alive is a second copy, not a move — the
4974    /// rule that keeps a second paste from minting a duplicate id.
4975    #[test]
4976    fn pasting_beside_a_live_original_duplicates_it() {
4977        use crate::widget::test_fixtures as fx;
4978        let ctx = egui::Context::default();
4979        let block = blockworx_doc::fixtures::block_id(2);
4980        let mut app = app_on(vec![fx::block(1, 0.0), fx::block(2, 120.0)]);
4981        let before = live_blocks(&app);
4982        let payload = app
4983            .session
4984            .drawing()
4985            .copy_selection(&[crate::shape::ShapeId::Rect(block)])
4986            .and_then(|clip| clip.to_json())
4987            .expect("a selected block copies");
4988
4989        app.dispatch_action(&ctx, Action::Paste(payload));
4990
4991        assert!(
4992            holds_block(&app, block),
4993            "the original was consumed by a copy"
4994        );
4995        assert_eq!(
4996            live_blocks(&app),
4997            before + 1,
4998            "the paste added no second block",
4999        );
5000    }
5001
5002    /// A multi-select copied out of one document and pasted into another must
5003    /// land whole: every block, the wire between two of them, their pins, and
5004    /// the icon artwork — alive in the document *and* in the level's spatial
5005    /// index, which is what the canvas draws and hit-tests from.
5006    #[test]
5007    fn a_cross_document_paste_lands_every_block() {
5008        use crate::path::Scope;
5009        use crate::shape::ShapeId;
5010        use crate::widget::spatial::{HitId, SpatialIndex};
5011        use crate::widget::test_fixtures as fx;
5012        use blockworx_doc::fixtures::block_id;
5013
5014        let ctx = egui::Context::default();
5015        let sheet = Rect::from_min_max(pos2(0.0, 0.0), pos2(900.0, 400.0));
5016        let body = |x: f32| Rect::from_min_max(pos2(x, 40.0), pos2(x + 60.0, 100.0));
5017        let mut source = app_on(vec![
5018            fx::block_in(1, Scope::Root, sheet),
5019            fx::top(1),
5020            fx::block_in(2, Scope::Block(block_id(1)), body(40.0)),
5021            fx::block_in(3, Scope::Block(block_id(1)), body(200.0)),
5022            fx::block_in(4, Scope::Block(block_id(1)), body(360.0)),
5023            fx::pin(5, 2, crate::shape::pin::PinSide::East, 0),
5024            fx::pin(6, 3, crate::shape::pin::PinSide::West, 0),
5025            fx::route(7, Scope::Block(block_id(1)), 5, 6, &[]),
5026            fx::asset().1,
5027            fx::icon(4, Rect::from_min_max(pos2(370.0, 50.0), pos2(390.0, 70.0))),
5028        ]);
5029        let copied_blocks = [
5030            ShapeId::Rect(block_id(2)),
5031            ShapeId::Rect(block_id(3)),
5032            ShapeId::Rect(block_id(4)),
5033        ];
5034        assert_eq!(
5035            source.session.path.segments(),
5036            [block_id(1)],
5037            "precondition: the source editor is inside its top block",
5038        );
5039
5040        source.dispatch_action(&ctx, Action::Copy(copied_blocks.to_vec()));
5041        let payload = copied(&ctx).expect("a multi-select copy reaches the clipboard");
5042
5043        let mut target = app_on(vec![
5044            fx::block_in(10, Scope::Root, sheet),
5045            fx::top(10),
5046            fx::block_in(11, Scope::Block(block_id(10)), body(40.0)),
5047        ]);
5048        let before = live_blocks(&target);
5049        target.dispatch_action(&ctx, Action::Paste(payload));
5050
5051        assert_eq!(
5052            live_blocks(&target),
5053            before + 3,
5054            "the paste did not land all three blocks",
5055        );
5056        let doc = target.session.doc.repo().document();
5057        assert_eq!(doc.pins().count(), 2, "the pasted pins did not arrive");
5058        assert_eq!(doc.routes().count(), 1, "the pasted wire did not arrive");
5059        assert!(
5060            doc.asset(&fx::asset().0).is_some(),
5061            "the icon's artwork did not travel with the paste",
5062        );
5063
5064        let mut probe = app_on(vec![]);
5065        std::mem::swap(&mut probe, &mut target);
5066        let drawn: Vec<HitId> = {
5067            let drawing = probe.session.drawing();
5068            SpatialIndex::from_drawing(&drawing)
5069                .in_rect(Rect::EVERYTHING)
5070                .collect()
5071        };
5072        let blocks_drawn = drawn
5073            .iter()
5074            .filter(|id| matches!(id, HitId::Shape(ShapeId::Rect(_))))
5075            .count();
5076        assert_eq!(
5077            blocks_drawn, 4,
5078            "the pasted blocks are in the log but not on the level: {drawn:?}",
5079        );
5080    }
5081
5082    /// A paste wider than the viewport, then a pan to the part that landed
5083    /// off-screen — with no further commit of any kind. Everything pasted
5084    /// must paint and hit-test out there.
5085    ///
5086    /// The caches the canvas derives from — the level's spatial index and
5087    /// the solved wire geometry — are keyed by the document *value*, so the
5088    /// paste's own commit is what re-derives them; a later camera move must
5089    /// never be what a pasted entity was waiting for. The control asserts
5090    /// culling was live, or the pan would prove nothing.
5091    #[test]
5092    fn a_paste_wider_than_the_viewport_paints_where_it_landed_off_screen() {
5093        use crate::panels::painted::Chrome;
5094        use crate::path::Scope;
5095        use crate::shape::ShapeId;
5096        use crate::tools::tool::ToolTrait as _;
5097        use crate::widget::test_fixtures as fx;
5098        use blockworx_doc::fixtures::block_id;
5099
5100        let ctx = egui::Context::default();
5101        let sheet = Rect::from_min_max(pos2(0.0, 0.0), pos2(2400.0, 800.0));
5102        let body = |x: f32| Rect::from_min_max(pos2(x, 300.0), pos2(x + 60.0, 360.0));
5103        let mut app = app_on(vec![
5104            fx::block_in(1, Scope::Root, sheet),
5105            fx::top(1),
5106            fx::block_in(2, Scope::Block(block_id(1)), body(40.0)),
5107            fx::block_in(3, Scope::Block(block_id(1)), body(640.0)),
5108            fx::block_in(4, Scope::Block(block_id(1)), body(1240.0)),
5109            fx::pin(5, 3, crate::shape::pin::PinSide::East, 0),
5110            fx::pin(6, 4, crate::shape::pin::PinSide::West, 0),
5111            fx::route(7, Scope::Block(block_id(1)), 5, 6, &[]),
5112        ]);
5113        let selection = [
5114            ShapeId::Rect(block_id(2)),
5115            ShapeId::Rect(block_id(3)),
5116            ShapeId::Rect(block_id(4)),
5117        ];
5118
5119        let mut chrome = Chrome::new(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)));
5120        chrome.settle(|ui| {
5121            app.show_canvas(ui);
5122        });
5123        // A viewport far narrower than what is about to be pasted, so the
5124        // paste lands mostly outside it.
5125        app.canvas.fit_to_rect_instant(Rect::from_center_size(
5126            pos2(800.0, 330.0),
5127            vec2(700.0, 500.0),
5128        ));
5129        chrome.settle(|ui| {
5130            app.show_canvas(ui);
5131        });
5132
5133        app.dispatch_action(&ctx, Action::Copy(selection.to_vec()));
5134        let payload = copied(&ctx).expect("a multi-select copy reaches the clipboard");
5135        app.dispatch_action(&ctx, Action::Paste(payload));
5136        // A paste now frames what it landed; this test is about what paints
5137        // *outside* the view, so put the narrow window back — which also
5138        // cancels the framing the paste asked for.
5139        app.canvas.fit_to_rect_instant(Rect::from_center_size(
5140            pos2(800.0, 330.0),
5141            vec2(700.0, 500.0),
5142        ));
5143        chrome.settle(|ui| {
5144            app.show_canvas(ui);
5145        });
5146
5147        let landed: Vec<ShapeId> = app
5148            .session
5149            .tool
5150            .selection()
5151            .and_then(|d| d.shapes())
5152            .expect("the paste selects what it landed");
5153        let visible = app.canvas.visible_world_rect();
5154        let mut off_screen = landed
5155            .iter()
5156            .copied()
5157            .map(|id| (id, block_rect_of(&mut app, id.block().expect("a block"))))
5158            .filter(|(_, world)| !visible.intersects(*world));
5159        let (off_id, off_world) = off_screen.next().expect(
5160            "precondition: something the paste landed must be outside the viewport, or this \
5161             test proves nothing",
5162        );
5163
5164        // The control: culling has to be live in the harness, or panning
5165        // proves nothing.
5166        assert!(
5167            !chrome.outlines().iter().any(|drawn| drawn
5168                .min
5169                .distance(Camera::of(&app.canvas).place(off_world).min)
5170                < 4.0),
5171            "precondition: the off-screen block painted without a pan — nothing is culling",
5172        );
5173
5174        // The pan: no commit, no edit — only the camera moves to what the
5175        // paste put out there.
5176        app.canvas.fit_to_rect_instant(off_world.expand(80.0));
5177        chrome.settle(|ui| {
5178            app.show_canvas(ui);
5179        });
5180
5181        let camera = Camera::of(&app.canvas);
5182        let want = camera.place(off_world);
5183        let near = |a: Rect, b: Rect| a.min.distance(b.min) < 4.0 && a.max.distance(b.max) < 4.0;
5184        assert!(
5185            chrome.outlines().iter().any(|drawn| near(*drawn, want)),
5186            "panning to a pasted block ({off_id:?}, world {off_world:?}) painted nothing at \
5187             {want:?}: {:?}",
5188            chrome.outlines(),
5189        );
5190
5191        // The wire between two pasted blocks, at the same camera: its
5192        // geometry must be materialized and it must actually paint.
5193        let route = app
5194            .session
5195            .doc
5196            .repo()
5197            .document()
5198            .routes()
5199            .filter(|(id, _)| *id != blockworx_doc::fixtures::route_id(7))
5200            .map(|(id, _)| id)
5201            .next()
5202            .expect("the paste landed a wire of its own");
5203        assert!(
5204            app.session.drawing().route_geometry(route).is_some(),
5205            "the pasted wire {route:?} has no solved geometry after the pan",
5206        );
5207        let wire_world = {
5208            let drawing = app.session.drawing();
5209            let geometry = drawing.route_geometry(route).expect("just checked");
5210            blockworx_editor::render::bounds::route_bounds(
5211                &drawing
5212                    .auto_route(route)
5213                    .expect("the wire is on this level"),
5214                geometry,
5215            )
5216        };
5217        let wire_screen = camera.place(wire_world);
5218        assert!(
5219            chrome
5220                .segments()
5221                .iter()
5222                .any(|[a, b]| wire_screen.contains(*a) && wire_screen.contains(*b)),
5223            "the pasted wire {route:?} (world {wire_world:?}) painted no line inside \
5224             {wire_screen:?}",
5225        );
5226
5227        // The other consumer of the same index: the cached index the frames
5228        // just used must answer a point query where the block was panned to,
5229        // or a click out here would select nothing.
5230        let center = off_world.center();
5231        let doc = super::viewed(&app.session.doc, app.session.time_machine.as_ref()).document();
5232        let hits: Vec<crate::widget::spatial::HitId> = app
5233            .session
5234            .spatial
5235            .get(
5236                &mut app.session.doc_index,
5237                doc,
5238                &app.session.path,
5239                &mut app.session.presentation,
5240            )
5241            .in_rect(Rect::from_min_max(center, center))
5242            .collect();
5243        assert!(
5244            hits.contains(&crate::widget::spatial::HitId::Shape(off_id)),
5245            "the index the frame drew from does not hold {off_id:?} at {center:?}: {hits:?}",
5246        );
5247        let on_wire = wire_world.center();
5248        let wire_hits: Vec<crate::widget::spatial::HitId> = app
5249            .session
5250            .spatial
5251            .get(
5252                &mut app.session.doc_index,
5253                doc,
5254                &app.session.path,
5255                &mut app.session.presentation,
5256            )
5257            .in_rect(Rect::from_min_max(on_wire, on_wire))
5258            .collect();
5259        assert!(
5260            wire_hits.contains(&crate::widget::spatial::HitId::Route(route)),
5261            "the index does not hold the pasted wire {route:?} at {on_wire:?}: {wire_hits:?}",
5262        );
5263    }
5264
5265    /// G6b: a group whose members are committed *outside* the viewport,
5266    /// dragged until their preview is inside it. Mid-drag — before any
5267    /// release — the off-screen members must paint where the preview puts
5268    /// them, and so must both kinds of wire the drag moves: one between two
5269    /// dragged members, and one from a dragged member to a stationary block
5270    /// still off screen.
5271    ///
5272    /// The cull set comes from the spatial index, which is keyed by the
5273    /// document value and so knows only committed rects; a drag moves where a
5274    /// shape paints without moving its rect, so the preview has to be counted
5275    /// in the visibility question or it cannot draw at all. The release is the
5276    /// control: once the move commits, the same content paints from the index.
5277    #[test]
5278    fn a_drag_paints_off_screen_content_where_its_preview_lands() {
5279        use crate::panels::painted::Chrome;
5280        use crate::path::Scope;
5281        use crate::shape::ShapeId;
5282        use crate::tools::{MultiSelect, tool::Tool};
5283        use crate::widget::test_fixtures as fx;
5284        use blockworx_doc::fixtures::block_id;
5285
5286        let sheet = Rect::from_min_max(pos2(0.0, 0.0), pos2(2400.0, 800.0));
5287        let body = |x: f32| Rect::from_min_max(pos2(x, 300.0), pos2(x + 50.0, 360.0));
5288        let mut app = app_on(vec![
5289            fx::block_in(1, Scope::Root, sheet),
5290            fx::top(1),
5291            fx::block_in(2, Scope::Block(block_id(1)), body(600.0)),
5292            fx::block_in(3, Scope::Block(block_id(1)), body(900.0)),
5293            fx::block_in(4, Scope::Block(block_id(1)), body(1100.0)),
5294            fx::block_in(8, Scope::Block(block_id(1)), body(1500.0)),
5295            fx::pin(5, 3, crate::shape::pin::PinSide::East, 0),
5296            fx::pin(6, 4, crate::shape::pin::PinSide::West, 0),
5297            fx::route(7, Scope::Block(block_id(1)), 5, 6, &[]),
5298            fx::pin(9, 4, crate::shape::pin::PinSide::East, 0),
5299            fx::pin(10, 8, crate::shape::pin::PinSide::West, 0),
5300            fx::route(11, Scope::Block(block_id(1)), 9, 10, &[]),
5301        ]);
5302        let group = vec![
5303            ShapeId::Rect(block_id(2)),
5304            ShapeId::Rect(block_id(3)),
5305            ShapeId::Rect(block_id(4)),
5306        ];
5307
5308        let mut chrome = Chrome::new(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)));
5309        chrome.settle(|ui| {
5310            app.show_canvas(ui);
5311        });
5312        // A window over the left of the sheet: block 2 is inside it; blocks 3,
5313        // 4 and 8 and both wires are well outside.
5314        app.canvas.fit_to_rect_instant(Rect::from_center_size(
5315            pos2(300.0, 330.0),
5316            vec2(700.0, 500.0),
5317        ));
5318        chrome.settle(|ui| {
5319            app.show_canvas(ui);
5320        });
5321        app.session.tool = Tool::MultiSelect(MultiSelect::Selected { shapes: group });
5322
5323        // The wire between two dragged members, and the one tethering a
5324        // dragged member to the block that stays behind.
5325        let (inner, tether) = (
5326            blockworx_doc::fixtures::route_id(7),
5327            blockworx_doc::fixtures::route_id(11),
5328        );
5329        let wire_world = |app: &mut App, route| {
5330            let drawing = app.session.drawing();
5331            let geometry = drawing
5332                .route_geometry(route)
5333                .expect("the wire has solved geometry");
5334            blockworx_editor::render::bounds::route_bounds(
5335                &drawing
5336                    .auto_route(route)
5337                    .expect("the wire is on this level"),
5338                geometry,
5339            )
5340        };
5341        let visible = app.canvas.visible_world_rect();
5342        let grabbed = block_rect_of(&mut app, block_id(2));
5343        let (far, farther) = (
5344            block_rect_of(&mut app, block_id(3)),
5345            block_rect_of(&mut app, block_id(4)),
5346        );
5347        let behind = block_rect_of(&mut app, block_id(8));
5348        let wire = wire_world(&mut app, inner);
5349        assert!(
5350            visible.contains_rect(grabbed),
5351            "precondition: the group needs an on-screen member to grab ({grabbed:?} in \
5352             {visible:?})",
5353        );
5354        for (what, rect) in [
5355            ("block 3", far),
5356            ("block 4", farther),
5357            ("block 8", behind),
5358            ("the inner wire", wire),
5359            ("the tether", wire_world(&mut app, tether)),
5360        ] {
5361            assert!(
5362                !visible.intersects(rect),
5363                "precondition: {what} ({rect:?}) must start outside {visible:?}, or the drag \
5364                 proves nothing",
5365            );
5366        }
5367
5368        // The drag: grab the on-screen member and pull the group left until the
5369        // off-screen half is in view. No release.
5370        let camera = Camera::of(&app.canvas);
5371        let start = camera.place(grabbed).center();
5372        let pull = -640.0 * camera.zoom;
5373        chrome.press_at(start, |ui| {
5374            app.show_canvas(ui);
5375        });
5376        chrome.drag_to(start + vec2(-24.0, 0.0), |ui| {
5377            app.show_canvas(ui);
5378        });
5379        chrome.drag_to(start + vec2(pull, 0.0), |ui| {
5380            app.show_canvas(ui);
5381        });
5382
5383        let Tool::MultiSelect(MultiSelect::Moving { delta_pos, .. }) = &app.session.tool else {
5384            panic!("the grab did not put the group into a drag: {:?}", {
5385                use crate::tools::tool::ToolTrait as _;
5386                app.session.tool.name()
5387            });
5388        };
5389        let delta = *delta_pos;
5390        let previewed_wire = wire.translate(delta);
5391        for (what, rect) in [
5392            ("block 3", far.translate(delta)),
5393            ("block 4", farther.translate(delta)),
5394            ("the inner wire", previewed_wire),
5395        ] {
5396            assert!(
5397                visible.contains_rect(rect),
5398                "precondition: the drag must bring {what} fully into {visible:?}, but it \
5399                 previews at {rect:?}",
5400            );
5401        }
5402
5403        let near = |a: Rect, b: Rect| a.min.distance(b.min) < 4.0 && a.max.distance(b.max) < 4.0;
5404        // A line kept inside `within` that still spans its middle: the wire runs
5405        // the whole gap between the blocks it joins, where a pin stub reaches
5406        // only part way and an alignment guide runs off both ends.
5407        let spans = |within: Rect| {
5408            let middle = within.center();
5409            move |[a, b]: &[Pos2; 2]| {
5410                within.contains(*a)
5411                    && within.contains(*b)
5412                    && a.x.min(b.x) <= middle.x
5413                    && middle.x <= a.x.max(b.x)
5414            }
5415        };
5416        for (what, rect) in [
5417            ("block 3", far.translate(delta)),
5418            ("block 4", farther.translate(delta)),
5419        ] {
5420            let want = camera.place(rect);
5421            assert!(
5422                chrome.outlines().iter().any(|drawn| near(*drawn, want)),
5423                "mid-drag, {what} previewing at world {rect:?} painted nothing at {want:?}: \
5424                 {:?}",
5425                chrome.outlines(),
5426            );
5427        }
5428        let wire_screen = camera.place(previewed_wire);
5429        assert!(
5430            chrome.segments().iter().any(spans(wire_screen)),
5431            "mid-drag, the inner wire previewing at world {previewed_wire:?} painted no line \
5432             spanning {wire_screen:?}: {:?}",
5433            chrome.segments(),
5434        );
5435        // The tether: only one of its ends moved, so it previews as the run
5436        // from block 4's new position out to the block that stayed behind —
5437        // crossing the viewport on the way, having been wholly outside it.
5438        let previewed_tether = farther.translate(delta).union(behind);
5439        assert!(
5440            visible.intersects(previewed_tether),
5441            "precondition: the tether must cross {visible:?} mid-drag",
5442        );
5443        let tether_screen = camera.place(previewed_tether);
5444        assert!(
5445            chrome.segments().iter().any(spans(tether_screen)),
5446            "mid-drag, the tether from the dragged block to the one left behind painted no \
5447             line spanning {tether_screen:?}: {:?}",
5448            chrome.segments(),
5449        );
5450
5451        // The control: the release commits the move, and the same content
5452        // paints from the refreshed index.
5453        chrome.release_at(start + vec2(pull, 0.0), |ui| {
5454            app.show_canvas(ui);
5455        });
5456        for (what, id) in [("block 3", block_id(3)), ("block 4", block_id(4))] {
5457            let want = camera.place(block_rect_of(&mut app, id));
5458            assert!(
5459                chrome.outlines().iter().any(|drawn| near(*drawn, want)),
5460                "after the release, {what} painted nothing at {want:?}: {:?}",
5461                chrome.outlines(),
5462            );
5463        }
5464        for (what, route) in [("the inner wire", inner), ("the tether", tether)] {
5465            let committed = camera.place(wire_world(&mut app, route));
5466            assert!(
5467                chrome.segments().iter().any(spans(committed)),
5468                "after the release, {what} painted no line spanning {committed:?}",
5469            );
5470        }
5471    }
5472
5473    /// A settled canvas showing a known window of a sheet, with one small
5474    /// block inside it to copy — the scene the paste-framing tests share.
5475    /// They differ only in where the paste is aimed and what lands.
5476    fn a_view_to_paste_into() -> (App, crate::panels::painted::Chrome) {
5477        use crate::path::Scope;
5478        use crate::widget::test_fixtures as fx;
5479        use blockworx_doc::fixtures::block_id;
5480
5481        let mut app = app_on(vec![
5482            fx::block_in(
5483                1,
5484                Scope::Root,
5485                Rect::from_min_max(pos2(0.0, 0.0), pos2(2400.0, 800.0)),
5486            ),
5487            fx::top(1),
5488            fx::block_in(
5489                2,
5490                Scope::Block(block_id(1)),
5491                Rect::from_min_max(pos2(40.0, 300.0), pos2(120.0, 380.0)),
5492            ),
5493        ]);
5494        let mut chrome = crate::panels::painted::Chrome::new(Rect::from_min_size(
5495            Pos2::ZERO,
5496            vec2(800.0, 600.0),
5497        ));
5498        chrome.settle(|ui| {
5499            app.show_canvas(ui);
5500        });
5501        app.canvas.fit_to_rect_instant(Rect::from_center_size(
5502            pos2(800.0, 330.0),
5503            vec2(700.0, 500.0),
5504        ));
5505        chrome.settle(|ui| {
5506            app.show_canvas(ui);
5507        });
5508        (app, chrome)
5509    }
5510
5511    /// Frames enough for a camera easing toward its target to arrive.
5512    fn settle_camera(app: &mut App, chrome: &mut crate::panels::painted::Chrome) {
5513        for _ in 0..16 {
5514            chrome.settle(|ui| {
5515                app.show_canvas(ui);
5516            });
5517        }
5518    }
5519
5520    /// The world box around everything the selection holds.
5521    fn selection_bounds(app: &mut App) -> Rect {
5522        use crate::tools::tool::ToolTrait as _;
5523        let shapes = app
5524            .session
5525            .tool
5526            .selection()
5527            .and_then(|d| d.shapes())
5528            .expect("the paste selects what it landed");
5529        shapes
5530            .iter()
5531            .filter_map(|&id| Some(app.session.drawing().shape(id)?.gui_rect()))
5532            .reduce(Rect::union)
5533            .expect("what landed has bounds")
5534    }
5535
5536    fn camera(app: &App) -> (f32, Vec2) {
5537        (app.canvas.zoom.get(), app.canvas.translation)
5538    }
5539
5540    /// A copied selection pasted into the far corner of the view: paste puts
5541    /// the group's top-left under the pointer, so most of it lands outside.
5542    /// What a paste drops has never been on screen before, which makes
5543    /// content that lands out of sight indistinguishable from content that
5544    /// never landed (G6).
5545    #[test]
5546    fn a_paste_that_lands_off_screen_is_brought_into_view() {
5547        use crate::shape::ShapeId;
5548        use blockworx_doc::fixtures::block_id;
5549
5550        let ctx = egui::Context::default();
5551        let (mut app, mut chrome) = a_view_to_paste_into();
5552        let before = app.canvas.visible_world_rect();
5553        app.session
5554            .note_pointer(Some(blockworx_paint::Event::HoverAt(
5555                before.max - vec2(4.0, 4.0),
5556            )));
5557
5558        app.dispatch_action(&ctx, Action::Copy(vec![ShapeId::Rect(block_id(2))]));
5559        let payload = copied(&ctx).expect("the copy reaches the clipboard");
5560        app.dispatch_action(&ctx, Action::Paste(payload));
5561
5562        let landed = selection_bounds(&mut app);
5563        assert!(
5564            !before.contains_rect(landed),
5565            "precondition: {landed:?} landed inside {before:?}, so this test proves nothing",
5566        );
5567
5568        settle_camera(&mut app, &mut chrome);
5569        assert!(
5570            app.canvas.visible_world_rect().contains_rect(landed),
5571            "the paste left {landed:?} outside the view: {:?}",
5572            app.canvas.visible_world_rect(),
5573        );
5574        assert_eq!(
5575            crate::tools::tool::ToolTrait::selection(&app.session.tool)
5576                .and_then(|d| d.shapes())
5577                .unwrap_or_default()
5578                .len(),
5579            1,
5580            "framing what landed must not disturb the selection it left",
5581        );
5582    }
5583
5584    /// The other half of the policy: a paste that lands where it can already
5585    /// be seen moves the camera not one pixel. A gratuitous jump on every
5586    /// paste would be its own bug.
5587    #[test]
5588    fn a_paste_that_lands_in_view_does_not_move_the_camera() {
5589        use crate::shape::ShapeId;
5590        use blockworx_doc::fixtures::block_id;
5591
5592        let ctx = egui::Context::default();
5593        let (mut app, mut chrome) = a_view_to_paste_into();
5594        let before = app.canvas.visible_world_rect();
5595        app.session
5596            .note_pointer(Some(blockworx_paint::Event::HoverAt(before.center())));
5597        let camera_before = camera(&app);
5598
5599        app.dispatch_action(&ctx, Action::Copy(vec![ShapeId::Rect(block_id(2))]));
5600        let payload = copied(&ctx).expect("the copy reaches the clipboard");
5601        app.dispatch_action(&ctx, Action::Paste(payload));
5602
5603        let landed = selection_bounds(&mut app);
5604        assert!(
5605            before.contains_rect(landed),
5606            "precondition: {landed:?} landed outside {before:?}, so this test proves nothing",
5607        );
5608
5609        settle_camera(&mut app, &mut chrome);
5610        assert_eq!(
5611            camera(&app),
5612            camera_before,
5613            "a paste that landed in plain sight moved the camera",
5614        );
5615    }
5616
5617    /// The same pair for the document insert (D19), which lands through the
5618    /// same door a paste does.
5619    #[test]
5620    fn an_inserted_document_is_brought_into_view_only_when_it_needs_to_be() {
5621        use crate::path::Scope;
5622        use crate::widget::test_fixtures as fx;
5623
5624        let ctx = egui::Context::default();
5625        let repo = blockworx_doc::repo::Repo::folding(&[blockworx_doc::commit::Commit::new(
5626            "Built a document".into(),
5627            vec![
5628                fx::block_in(
5629                    1,
5630                    Scope::Root,
5631                    Rect::from_min_max(pos2(0.0, 0.0), pos2(120.0, 120.0)),
5632                ),
5633                fx::top(1),
5634            ],
5635        )])
5636        .expect("the document folds");
5637        let payload = blockworx_store::projection::export_text(
5638            repo.document(),
5639            blockworx_store::projection::Stamp::at(
5640                repo.rev(),
5641                blockworx_store::record::Digest::of(&[]),
5642            ),
5643            blockworx_store::projection::Source {
5644                document: "inserted".into(),
5645                author: "tester".into(),
5646                tags: Vec::new(),
5647            },
5648        );
5649        assert!(
5650            blockworx_editor::import::from_clipboard(&payload).is_some(),
5651            "precondition: an export reads as a document insert",
5652        );
5653
5654        let (mut app, mut chrome) = a_view_to_paste_into();
5655        let before = app.canvas.visible_world_rect();
5656        app.session
5657            .note_pointer(Some(blockworx_paint::Event::HoverAt(
5658                before.max - vec2(4.0, 4.0),
5659            )));
5660        app.dispatch_action(&ctx, Action::Paste(payload.clone()));
5661        let landed = selection_bounds(&mut app);
5662        assert!(
5663            !before.contains_rect(landed),
5664            "precondition: the insert landed inside the view it was aimed past",
5665        );
5666        settle_camera(&mut app, &mut chrome);
5667        assert!(
5668            app.canvas.visible_world_rect().contains_rect(landed),
5669            "the insert left {landed:?} outside the view",
5670        );
5671
5672        let (mut app, mut chrome) = a_view_to_paste_into();
5673        let before = app.canvas.visible_world_rect();
5674        app.session
5675            .note_pointer(Some(blockworx_paint::Event::HoverAt(before.center())));
5676        let camera_before = camera(&app);
5677        app.dispatch_action(&ctx, Action::Paste(payload));
5678        let landed = selection_bounds(&mut app);
5679        assert!(
5680            before.contains_rect(landed),
5681            "precondition: {landed:?} landed outside {before:?}",
5682        );
5683        settle_camera(&mut app, &mut chrome);
5684        assert_eq!(
5685            camera(&app),
5686            camera_before,
5687            "an insert that landed in plain sight moved the camera",
5688        );
5689    }
5690
5691    /// A whole document arriving on the clipboard (D19) must bring every block
5692    /// it holds. `top` names the level the editor opens on, not the outermost
5693    /// block: this document was wrapped twice without re-pointing it, so
5694    /// following `top` leaves the two blocks above it behind.
5695    #[test]
5696    fn pasting_a_whole_document_lands_every_block_it_holds() {
5697        use crate::path::Scope;
5698        use crate::widget::test_fixtures as fx;
5699        use blockworx_doc::fixtures::block_id;
5700
5701        let ctx = egui::Context::default();
5702        let sheet = Rect::from_min_max(pos2(0.0, 0.0), pos2(600.0, 400.0));
5703        let repo = blockworx_doc::repo::Repo::folding(&[blockworx_doc::commit::Commit::new(
5704            "Built a document".into(),
5705            vec![
5706                fx::block_in(1, Scope::Root, sheet),
5707                fx::block_in(2, Scope::Block(block_id(1)), sheet),
5708                fx::block_in(
5709                    3,
5710                    Scope::Block(block_id(2)),
5711                    Rect::from_min_max(pos2(40.0, 40.0), pos2(120.0, 120.0)),
5712                ),
5713                // The stale pointer: `top` still names the inner sheet the two
5714                // wraps above it demoted.
5715                fx::top(2),
5716            ],
5717        )])
5718        .expect("the document folds");
5719        let held = repo.document().blocks().count();
5720        assert_eq!(held, 3, "precondition: the document holds three blocks");
5721        assert_ne!(
5722            repo.document().title_block().top,
5723            block_id(1),
5724            "precondition: `top` is not the outermost block",
5725        );
5726
5727        let payload = blockworx_store::projection::export_text(
5728            repo.document(),
5729            blockworx_store::projection::Stamp::at(
5730                repo.rev(),
5731                blockworx_store::record::Digest::of(&[]),
5732            ),
5733            blockworx_store::projection::Source {
5734                document: "wrapped".into(),
5735                author: "tester".into(),
5736                tags: Vec::new(),
5737            },
5738        );
5739        assert!(
5740            blockworx_editor::import::from_clipboard(&payload).is_some(),
5741            "precondition: an export reads as a document insert",
5742        );
5743
5744        let mut target = app_on(vec![]);
5745        let before = live_blocks(&target);
5746        target.dispatch_action(&ctx, Action::Paste(payload));
5747
5748        assert_eq!(
5749            live_blocks(&target) - before,
5750            held,
5751            "the inserted document did not bring every block it holds",
5752        );
5753    }
5754
5755    /// The other way a document outgrows its `top`: a block drawn beside the
5756    /// sheet, at the document root. The insert takes the root scope, so it
5757    /// travels too.
5758    #[test]
5759    fn pasting_a_document_brings_the_blocks_beside_its_top() {
5760        use crate::path::Scope;
5761        use crate::widget::test_fixtures as fx;
5762        use blockworx_doc::fixtures::block_id;
5763
5764        let ctx = egui::Context::default();
5765        let sheet = Rect::from_min_max(pos2(0.0, 0.0), pos2(600.0, 400.0));
5766        let repo = blockworx_doc::repo::Repo::folding(&[blockworx_doc::commit::Commit::new(
5767            "Built a document".into(),
5768            vec![
5769                fx::block_in(1, Scope::Root, sheet),
5770                fx::top(1),
5771                fx::block_in(
5772                    2,
5773                    Scope::Root,
5774                    Rect::from_min_max(pos2(700.0, 0.0), pos2(800.0, 100.0)),
5775                ),
5776            ],
5777        )])
5778        .expect("the document folds");
5779        let at_root = repo
5780            .document()
5781            .blocks()
5782            .filter(|(_, block)| block.parent == blockworx_doc::id::BlockId::NULL)
5783            .count();
5784        assert_eq!(at_root, 2, "precondition: the root holds two blocks");
5785        assert_eq!(
5786            repo.document().title_block().top,
5787            block_id(1),
5788            "precondition: `top` is only one of them",
5789        );
5790
5791        let payload = blockworx_store::projection::export_text(
5792            repo.document(),
5793            blockworx_store::projection::Stamp::at(
5794                repo.rev(),
5795                blockworx_store::record::Digest::of(&[]),
5796            ),
5797            blockworx_store::projection::Source {
5798                document: "beside".into(),
5799                author: "tester".into(),
5800                tags: Vec::new(),
5801            },
5802        );
5803        let mut target = app_on(vec![]);
5804        let before = live_blocks(&target);
5805        target.dispatch_action(&ctx, Action::Paste(payload));
5806
5807        assert_eq!(
5808            live_blocks(&target) - before,
5809            2,
5810            "the block beside the document's top did not travel with it",
5811        );
5812    }
5813
5814    /// The third thing a document's root scope holds: the wires between the
5815    /// blocks in it. The insert reads the exported file, so a wire the export
5816    /// could not spell arrived as nothing at all — every root block landing
5817    /// unconnected, which is not a rendering failure but a missing wire.
5818    #[test]
5819    fn pasting_a_document_brings_the_wires_of_its_root_scope() {
5820        use crate::path::Scope;
5821        use crate::shape::pin::PinSide;
5822        use crate::widget::test_fixtures as fx;
5823
5824        let ctx = egui::Context::default();
5825        let body = |x: f32| Rect::from_min_max(pos2(x, 100.0), pos2(x + 80.0, 180.0));
5826        let repo = blockworx_doc::repo::Repo::folding(&[blockworx_doc::commit::Commit::new(
5827            "Wired two blocks at the root".into(),
5828            vec![
5829                fx::block_in(1, Scope::Root, body(0.0)),
5830                fx::block_in(2, Scope::Root, body(400.0)),
5831                fx::top(1),
5832                fx::pin(3, 1, PinSide::East, 0),
5833                fx::pin(4, 2, PinSide::West, 0),
5834                fx::route(5, Scope::Root, 3, 4, &[]),
5835            ],
5836        )])
5837        .expect("the document folds");
5838        assert_eq!(
5839            repo.document()
5840                .routes()
5841                .filter(|(_, route)| route.owner == blockworx_doc::id::BlockId::NULL)
5842                .count(),
5843            1,
5844            "precondition: the wire this test follows is owned by the root scope",
5845        );
5846
5847        let payload = blockworx_store::projection::export_text(
5848            repo.document(),
5849            blockworx_store::projection::Stamp::at(
5850                repo.rev(),
5851                blockworx_store::record::Digest::of(&[]),
5852            ),
5853            blockworx_store::projection::Source {
5854                document: "wired".into(),
5855                author: "tester".into(),
5856                tags: Vec::new(),
5857            },
5858        );
5859
5860        let mut target = app_on(vec![]);
5861        let before = live_routes(&target);
5862        target.dispatch_action(&ctx, Action::Paste(payload));
5863
5864        assert_eq!(
5865            live_blocks(&target),
5866            2,
5867            "precondition: both ends of the wire landed",
5868        );
5869        assert_eq!(
5870            live_routes(&target) - before,
5871            1,
5872            "the inserted document's root-scope wire did not travel with it",
5873        );
5874    }
5875
5876    /// Pins cut and paste on their own gesture, onto a boundary rather than at
5877    /// a drop point — a separate pair of emitters reached through the same two
5878    /// actions. Note the asymmetry with the block above: `paste_pins` always
5879    /// mints fresh ids, so a cut pin never *moves* back the way a cut block
5880    /// does. It lands on whichever boundary the paste is aimed at, in the next
5881    /// free slot.
5882    #[test]
5883    fn cutting_pins_removes_them_and_pasting_slots_them_back() {
5884        use crate::widget::test_fixtures as fx;
5885        let ctx = egui::Context::default();
5886        let pin = blockworx_doc::fixtures::pin_id(3);
5887        let owner = blockworx_doc::fixtures::block_id(1);
5888        let mut app = app_on(vec![
5889            fx::block_in(
5890                1,
5891                crate::path::Scope::Root,
5892                Rect::from_min_max(pos2(0.0, 0.0), pos2(90.0, 300.0)),
5893            ),
5894            fx::pin(3, 1, crate::shape::pin::PinSide::West, 0),
5895        ]);
5896        let alive = |app: &App| app.session.doc.repo().document().pin(&pin).is_some();
5897        assert!(alive(&app), "precondition: there is a pin to cut");
5898        // The paste lands on the selected block's boundary, so the block is
5899        // what must be selected when it happens.
5900        app.session.tool = crate::tools::resize_block::ResizeBlock::Selected {
5901            shape: crate::shape::ShapeId::Rect(owner),
5902        }
5903        .into();
5904
5905        app.dispatch_action(&ctx, Action::CutPins(vec![pin]));
5906        assert!(!alive(&app), "the cut left the pin on the block");
5907        let payload = copied(&ctx).expect("a pin cut puts its snapshot on the clipboard");
5908
5909        app.session.tool = crate::tools::resize_block::ResizeBlock::Selected {
5910            shape: crate::shape::ShapeId::Rect(owner),
5911        }
5912        .into();
5913        app.dispatch_action(&ctx, Action::Paste(payload));
5914
5915        assert!(
5916            !alive(&app),
5917            "the cut pin's own id came back; paste_pins mints"
5918        );
5919        let on_owner: Vec<_> = app
5920            .session
5921            .doc
5922            .repo()
5923            .document()
5924            .pins()
5925            .filter(|(_, pin)| pin.owner == owner)
5926            .collect();
5927        assert_eq!(
5928            on_owner.len(),
5929            1,
5930            "the paste put no pin back on the block's boundary",
5931        );
5932    }
5933
5934    // ── The drawing's chrome ─────────────────────────────────────────────────
5935
5936    /// The title block reads the document *on the canvas*, so an edit moves
5937    /// the rev it shows and the time machine moves it back — padlocked, since
5938    /// a past rev is nobody's to edit.
5939    #[test]
5940    fn the_title_block_follows_the_rev_on_the_canvas() {
5941        use crate::widget::test_fixtures as fx;
5942        let ctx = egui::Context::default();
5943        let mut app = app_on(vec![fx::block(1, 0.0), fx::top(1)]);
5944        let rev = |app: &App| {
5945            app.title_block()
5946                .grid(&[])
5947                .cells()
5948                .find(|cell| cell.caption == "Rev:")
5949                .map(|cell| cell.value.text())
5950                .expect("the title block names a rev")
5951        };
5952        let stamped = app.title_block();
5953        assert_eq!(
5954            stamped.author, app.session.identity.name,
5955            "the author is the session's"
5956        );
5957        assert_eq!(
5958            app.session.may_write(),
5959            blockworx_store::doc::Writability::Writable
5960        );
5961        let before = rev(&app);
5962
5963        app.session.submit(blockworx_doc::commit::Commit::new(
5964            "Moved a block".to_owned(),
5965            vec![blockworx_store::fixture::block_move(1, 5)],
5966        ));
5967
5968        let after = rev(&app);
5969        assert_ne!(before, after, "a commit did not move the title block's rev");
5970        assert_eq!(after, app.session.doc.repo().rev().get().to_string());
5971
5972        app.dispatch_action(&ctx, Action::ViewRev(blockworx_doc::fixtures::rev(1)));
5973        assert_eq!(
5974            rev(&app),
5975            "1",
5976            "the title block is not stamped with the rev on the canvas"
5977        );
5978        assert_eq!(
5979            app.session.may_write(),
5980            blockworx_store::doc::Writability::ReadOnly,
5981            "a past rev is padlocked",
5982        );
5983    }
5984
5985    /// The Date row is the log's, not the clock's: it is the day the rev on
5986    /// the canvas was written, so walking back in time walks the date back
5987    /// with it — and two exports of one rev say the same thing forever. A
5988    /// scratch session records no wall times at all, and then the block
5989    /// simply has no date to give.
5990    #[cfg(not(target_arch = "wasm32"))]
5991    #[test]
5992    fn the_date_is_the_written_day_of_the_rev_on_the_canvas() {
5993        use crate::widget::test_fixtures as fx;
5994        use blockworx_store::handle::{Clock, Store};
5995        use blockworx_store::record::{Identity, WallTime};
5996        use blockworx_store::temp::TempDir;
5997
5998        let ctx = egui::Context::default();
5999        assert!(
6000            app_on(vec![fx::block(1, 0.0), fx::top(1)])
6001                .title_block()
6002                .date
6003                .is_none(),
6004            "a scratch session has no wall times, so it can have no date",
6005        );
6006
6007        let dir = TempDir::new("app-title-block-date");
6008        let root = dir.join("doc.bwx");
6009        // Two days apart, so the two revs cannot land on one date in any zone.
6010        let mut store = Store::create(
6011            &root,
6012            Clock::Pinned {
6013                at: WallTime::from_unix_millis(1_756_000_000_000),
6014                step: std::time::Duration::from_hours(48),
6015            },
6016        )
6017        .expect("the container");
6018        for (n, ops) in [
6019            (1, vec![fx::block(1, 0.0), fx::top(1)]),
6020            (2, vec![blockworx_store::fixture::block_move(1, 5)]),
6021        ] {
6022            store
6023                .submit_edit(
6024                    blockworx_doc::commit::Commit::new(format!("Edit {n}"), ops),
6025                    &Identity::new("ada"),
6026                )
6027                .expect("the edit lands");
6028        }
6029        let written: Vec<String> = store
6030            .rows()
6031            .iter()
6032            .map(|row| blockworx_store::history::date(row.wall_time))
6033            .collect();
6034        assert_eq!(written.len(), 2, "precondition: two rows were written");
6035        assert_ne!(
6036            written[0], written[1],
6037            "precondition: the two revs must fall on different days",
6038        );
6039        drop(store);
6040
6041        let mut app = App::new(AppConfig {
6042            opening: crate::app::Opening::Path(root),
6043            ..Default::default()
6044        });
6045        assert_eq!(app.title_block().date.as_deref(), Some(written[1].as_str()));
6046        app.dispatch_action(&ctx, Action::ViewRev(blockworx_doc::fixtures::rev(1)));
6047        assert_eq!(
6048            app.title_block().date.as_deref(),
6049            Some(written[0].as_str()),
6050            "the time machine showed one rev and the block dated another",
6051        );
6052    }
6053
6054    /// The bug the notices strip was rebuilt for: a load failure the user
6055    /// cannot get rid of. Acknowledged through the real click, it is gone —
6056    /// and stays gone, while the standing read-only line would not have been
6057    /// dismissible at all.
6058    #[test]
6059    fn an_acknowledged_failure_does_not_come_back() {
6060        use crate::panels::notices::Notice;
6061        use crate::panels::painted::Chrome;
6062        let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(900.0, 700.0));
6063        let toolbar = Rect::from_min_size(pos2(330.0, 8.0), vec2(240.0, 40.0));
6064        let mut app = app_on(Vec::new());
6065        app.report_failure("Failed to open /tmp/whatever.bwx: it is not a container".to_owned());
6066        assert!(
6067            matches!(app.notices().as_slice(), [Notice::Failure(_)]),
6068            "the failure did not reach the canvas",
6069        );
6070
6071        let mut chrome = Chrome::new(viewport);
6072        chrome.settle(|ui| app.show_document_notices(ui, viewport, toolbar));
6073        let dismiss = chrome
6074            .rect(crate::panels::notices::DISMISS)
6075            .expect("the failure drew an acknowledgement");
6076        chrome.click_at(dismiss.center(), |ui| {
6077            app.show_document_notices(ui, viewport, toolbar);
6078        });
6079
6080        assert!(
6081            app.notices().is_empty(),
6082            "the acknowledged failure came back"
6083        );
6084        chrome.settle(|ui| app.show_document_notices(ui, viewport, toolbar));
6085        assert!(
6086            chrome.texts().is_empty(),
6087            "the strip is still drawing: {:?}",
6088            chrome.texts(),
6089        );
6090    }
6091
6092    // ── The time machine (F7) ────────────────────────────────────────────────
6093    //
6094    // Every case here drives the real dispatch and the real registry: what
6095    // is withheld is withheld because the registry withheld it, and the
6096    // document on the canvas is whatever `viewed_document` hands the
6097    // drawing.
6098
6099    mod time_machine {
6100        use super::{Action, App};
6101        use crate::canvas::convert::IntoEgui as _;
6102        use crate::kernel::saturation;
6103        use crate::path::BlockPath;
6104        use crate::tools::commands::CommandId;
6105        use crate::tools::tool::ToolTrait as _;
6106        use crate::widget::test_fixtures as fx;
6107        use blockworx_doc::{commit::Commit, fixtures::block_id, repo::Repo, rev::Rev};
6108        use blockworx_geom::{Rect, pos2, vec2};
6109        use blockworx_paint::Saturation;
6110        use blockworx_store::doc::{Viewing, Writability};
6111
6112        /// The lens drains the canvas's color and the present restores it
6113        /// (spec §3.2).
6114        #[test]
6115        fn the_past_is_drawn_drained_of_color_and_the_present_is_not() {
6116            assert_eq!(saturation(Viewing::Head), Saturation::Full);
6117            assert_eq!(saturation(Viewing::Past(rev(23))), Saturation::Drained);
6118        }
6119
6120        /// The projection of what is on the canvas — the oracle for "which
6121        /// document is being shown". `content_hash` cannot serve: it covers
6122        /// the rev, and a rev-2 prefix fold and a rev-2 head are the same
6123        /// document only in what they hold.
6124        fn shown(app: &App) -> String {
6125            blockworx_store::document_file::to_json(app.session.viewed_repo().document())
6126        }
6127
6128        fn head(app: &App) -> String {
6129            blockworx_store::document_file::to_json(app.session.doc.repo().document())
6130        }
6131
6132        /// Three revs over one child block: draw it, move it, move it
6133        /// again — so rev 1 is visibly different from rev 3, and the block
6134        /// that moves is *inside* the top the editor opens on, where the
6135        /// canvas can see and select it.
6136        fn app_with_three_revs() -> App {
6137            let mut app = super::app_on(vec![
6138                fx::block(1, 0.0),
6139                fx::block_in(
6140                    2,
6141                    crate::path::Scope::Block(block_id(1)),
6142                    Rect::from_min_max(pos2(4.0, 4.0), pos2(24.0, 24.0)),
6143                ),
6144                fx::top(1),
6145            ]);
6146            for x in [5, 9] {
6147                app.session.submit(Commit::new(
6148                    format!("Moved to {x}"),
6149                    vec![blockworx_store::fixture::block_move(2, x)],
6150                ));
6151            }
6152            assert_eq!(
6153                app.session.doc.repo().rev(),
6154                rev(3),
6155                "three commits, three revs"
6156            );
6157            app
6158        }
6159
6160        fn rev(n: u64) -> Rev {
6161            blockworx_doc::fixtures::rev(n)
6162        }
6163
6164        fn commands(app: &mut App) -> crate::tools::commands::CommandSet {
6165            app.session
6166                .available_commands(crate::edit::naming::InterfaceLock::Unlocked)
6167        }
6168
6169        /// Selecting a rev puts *that* document on the canvas and takes the
6170        /// writing half of the registry away — while leaving everything a
6171        /// reader needs: select, navigate, zoom, copy, export.
6172        #[test]
6173        fn viewing_a_past_rev_shows_the_prefix_fold_and_withholds_the_writes() {
6174            let ctx = egui::Context::default();
6175            let mut app = app_with_three_revs();
6176            let at_head = head(&app);
6177            let at_two = blockworx_store::document_file::to_json(
6178                Repo::folding(&app.session.doc.repo().log()[..2])
6179                    .unwrap()
6180                    .document(),
6181            );
6182            assert_ne!(at_head, at_two, "precondition: the two revs differ");
6183
6184            app.dispatch_action(&ctx, Action::ViewRev(rev(2)));
6185
6186            assert_eq!(app.session.viewing(), Viewing::Past(rev(2)));
6187            assert_eq!(shown(&app), at_two, "the canvas is not showing rev 2");
6188            assert_eq!(head(&app), at_head, "viewing the past moved the head");
6189            assert_eq!(app.session.may_write(), Writability::ReadOnly);
6190
6191            let set = commands(&mut app);
6192            let authoring_tools = crate::tools::names::band_tools()
6193                .filter(|tool| tool.arming_writes_the_document())
6194                .map(CommandId::Arm);
6195            assert!(
6196                authoring_tools.clone().count() > 1,
6197                "the toolbar must carry authoring tools for this to check any",
6198            );
6199            for withheld in [
6200                CommandId::Undo,
6201                CommandId::Redo,
6202                CommandId::Delete,
6203                CommandId::Import,
6204            ]
6205            .into_iter()
6206            .chain(authoring_tools)
6207            {
6208                assert!(
6209                    !set.contains(withheld),
6210                    "{withheld:?} is invocable while viewing the past",
6211                );
6212            }
6213            for kept in [
6214                CommandId::Export(crate::export::ExportFormat::Json),
6215                CommandId::ZoomIn,
6216                CommandId::GoUp,
6217                CommandId::Arm(crate::tools::names::ToolName::Select),
6218            ] {
6219                assert!(set.contains(kept), "{kept:?} was withheld from a reader");
6220            }
6221        }
6222
6223        /// Export writes what is on screen, and while the time machine is
6224        /// open what is on screen is the past — under a stamp naming that
6225        /// rev, since every whole-document export is D19's artifact.
6226        #[test]
6227        fn export_writes_the_rev_on_the_canvas() {
6228            let ctx = egui::Context::default();
6229            let mut app = app_with_three_revs();
6230            app.dispatch_action(&ctx, Action::ViewRev(rev(1)));
6231            let exported = app
6232                .export_content(crate::export::ExportFormat::Json, None)
6233                .text()
6234                .expect("a JSON export is text")
6235                .to_owned();
6236            assert_eq!(
6237                blockworx_store::document_file::parse(&exported, "the export")
6238                    .expect("the export parses as a document"),
6239                blockworx_store::document_file::parse(&shown(&app), "the canvas")
6240                    .expect("and so does the canvas"),
6241                "the export is not the document being viewed",
6242            );
6243            let stamp = blockworx_store::projection::exported_stamp_in(&exported)
6244                .expect("the export is stamped");
6245            assert_eq!(stamp.rev, rev(1));
6246            assert_eq!(
6247                stamp.provenance.expect("and stamped with provenance").rev,
6248                rev(1),
6249                "the export names a rev other than the one on the canvas",
6250            );
6251        }
6252
6253        /// Copy-from-history, end to end: copy at a past rev, return to the
6254        /// present, paste. What lands is what rev 1 held — which is the
6255        /// whole point of the feature, and what the honest version of the
6256        /// undo/copy/redo dance buys.
6257        #[test]
6258        fn copy_out_of_the_past_pastes_into_the_present() {
6259            let ctx = egui::Context::default();
6260            let mut app = app_with_three_revs();
6261            let block = block_id(2);
6262            let rect_at = |app: &mut App| {
6263                app.session
6264                    .drawing()
6265                    .shape(crate::shape::ShapeId::Rect(block))
6266                    .expect("the block is on the canvas")
6267                    .gui_rect()
6268            };
6269            app.dispatch_action(&ctx, Action::ViewRev(rev(1)));
6270            // The pick opens the level rev 1 worked on (R57), which for this
6271            // fixture's one seeding commit is the root. The block being
6272            // copied lives a level in, so the reader goes back to it — which
6273            // is what a hand would do, and what this test is about.
6274            app.session.path = BlockPath::opening(app.session.viewed_document());
6275            let was = rect_at(&mut app);
6276
6277            app.dispatch_action(&ctx, Action::Copy(vec![crate::shape::ShapeId::Rect(block)]));
6278            let payload =
6279                super::copied(&ctx).expect("a copy in the past still reaches the clipboard");
6280
6281            app.dispatch_action(&ctx, Action::ViewHead);
6282            assert_eq!(app.session.viewing(), Viewing::Head);
6283            let before = super::live_blocks(&app);
6284            app.dispatch_action(&ctx, Action::Paste(payload));
6285            assert_eq!(
6286                super::live_blocks(&app),
6287                before + 1,
6288                "the paste did not land in the present",
6289            );
6290
6291            // The pasted copy is a fresh id carrying rev 1's geometry.
6292            let pasted = app
6293                .session
6294                .tool
6295                .selection()
6296                .and_then(|sel| sel.shapes())
6297                .and_then(|shapes| shapes.first().copied())
6298                .expect("the paste selects what it landed");
6299            let landed = app
6300                .session
6301                .drawing()
6302                .shape(pasted)
6303                .expect("the pasted block is on the canvas")
6304                .gui_rect();
6305            assert_eq!(
6306                landed.size(),
6307                was.size(),
6308                "what came back is not the block as rev 1 held it",
6309            );
6310        }
6311
6312        /// Going up is navigation, so a reader keeps it — but at the
6313        /// document root there is nowhere to go, the registry says so, and
6314        /// looking at the past is not something a dead button drops you
6315        /// out of. The writable half of this claim is
6316        /// `going_up_from_the_root_does_nothing_and_is_not_offered`.
6317        #[test]
6318        fn going_up_from_the_root_does_nothing_while_viewing_the_past() {
6319            let ctx = egui::Context::default();
6320            let mut app = app_with_three_revs();
6321            app.dispatch_action(&ctx, Action::ViewRev(rev(2)));
6322            // An editor opens inside the top block, so the root takes one
6323            // go-up to reach — and that first one is pure navigation.
6324            assert!(
6325                !app.session.path.segments().is_empty(),
6326                "an editor opens inside the top"
6327            );
6328            assert!(
6329                commands(&mut app).contains(CommandId::GoUp),
6330                "precondition: rising a level is navigation, which a reader keeps",
6331            );
6332            app.dispatch_action(&ctx, Action::GoUp);
6333            assert!(
6334                app.session.path.segments().is_empty(),
6335                "the first go-up pops to the root"
6336            );
6337
6338            let named_top = app.session.doc.document().title_block().top;
6339            let before = app.session.doc.repo().rev();
6340            app.dispatch_action(&ctx, Action::GoUp);
6341
6342            assert!(
6343                !commands(&mut app).contains(CommandId::GoUp),
6344                "the root offered a way up",
6345            );
6346            assert_eq!(
6347                app.session.doc.document().title_block().top,
6348                named_top,
6349                "a reader's go-up at the root wrapped the document",
6350            );
6351            assert_eq!(
6352                app.session.doc.repo().rev(),
6353                before,
6354                "and nothing reached the log"
6355            );
6356            assert_eq!(
6357                app.session.viewing(),
6358                Viewing::Past(rev(2)),
6359                "and left the time machine",
6360            );
6361        }
6362
6363        /// A rev whose blocks the current path descends through may predate
6364        /// them entirely; the path falls back rather than showing an empty
6365        /// scope that does not exist.
6366        #[test]
6367        fn a_path_that_the_viewed_rev_never_held_falls_back() {
6368            let ctx = egui::Context::default();
6369            let mut app = super::app_on(vec![fx::block(1, 0.0), fx::top(1)]);
6370            let deeper = Commit::new(
6371                "Added a child".into(),
6372                vec![fx::block_in(
6373                    2,
6374                    crate::path::Scope::Block(block_id(1)),
6375                    Rect::from_min_max(pos2(4.0, 4.0), pos2(24.0, 24.0)),
6376                )],
6377            );
6378            app.session.submit(deeper);
6379            app.session.path = BlockPath::to_parent_of(app.session.doc.document(), block_id(2))
6380                .expect("the child has a parent path");
6381            app.session.path.push(block_id(2));
6382            assert!(
6383                !app.session.path.segments().is_empty(),
6384                "precondition: the path descends"
6385            );
6386
6387            app.dispatch_action(&ctx, Action::ViewRev(rev(1)));
6388            assert!(
6389                app.session.path.is_held_by(app.session.viewed_document()),
6390                "the path still names a block rev 1 never held: {}",
6391                app.session.path,
6392            );
6393        }
6394
6395        /// A 1280×800 shell, wide enough for every band to lay out.
6396        fn screen() -> Rect {
6397            Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0))
6398        }
6399
6400        /// How many of band 2's creators this session would arm — the
6401        /// registry's own answer, which is what draws each button live or
6402        /// dead. Select is not counted: it authors nothing, and looking
6403        /// around a past rev is the whole point of the lens.
6404        fn armable_creators(app: &mut App) -> usize {
6405            let set = commands(app);
6406            crate::tools::names::band_tools()
6407                .filter(|name| name.arming_writes_the_document())
6408                .filter(|name| set.contains(CommandId::Arm(*name)))
6409                .count()
6410        }
6411
6412        /// Spec §3.2's three redundant signals, raised together in one real
6413        /// frame of the shell: the band names the rev on the canvas, the tool
6414        /// band goes inert, and the canvas drains its color. The failure the
6415        /// redundancy is for — modeling against a state that is not live — is
6416        /// severe, so all three are asserted at once and none may lapse alone.
6417        #[test]
6418        fn the_read_only_lens_raises_all_three_signals() {
6419            let ctx = super::shell_ctx();
6420            let mut app = app_with_three_revs();
6421            super::shell_frames(&mut app, &ctx, screen(), 4);
6422            let present = super::shell_text(&mut app, &ctx, screen());
6423            assert!(
6424                !present.iter().any(|said| said.starts_with("Rev ")),
6425                "precondition: the writable present raises no pill: {present:?}",
6426            );
6427            assert!(
6428                armable_creators(&mut app) > 0,
6429                "precondition: the present arms its creators",
6430            );
6431            assert_eq!(saturation(app.session.viewing()), Saturation::Full);
6432
6433            app.dispatch_action(&ctx, Action::ViewRev(rev(2)));
6434            super::shell_frames(&mut app, &ctx, screen(), 4);
6435            let past = super::shell_text(&mut app, &ctx, screen());
6436            assert!(
6437                past.iter().any(|said| said == "Rev 2"),
6438                "the pill does not name the rev on the canvas: {past:?}",
6439            );
6440            assert_eq!(
6441                armable_creators(&mut app),
6442                0,
6443                "the tool band is still live under the lens",
6444            );
6445            assert_eq!(
6446                saturation(app.session.viewing()),
6447                Saturation::Drained,
6448                "the canvas is still drawn in its own colors",
6449            );
6450        }
6451
6452        /// §2.0: the mode is a *state of the bar*, not a second object. The
6453        /// bar is the same box either way — nothing below it moves when the
6454        /// lens opens — and the region the canvas frames inside is unchanged
6455        /// with it, so a fit taken under the lens lands exactly where the
6456        /// same fit lands at the head.
6457        #[test]
6458        fn the_lens_changes_the_bars_state_and_moves_nothing_under_it() {
6459            let ctx = super::shell_ctx();
6460            let mut app = app_with_three_revs();
6461            super::shell_frames(&mut app, &ctx, screen(), 4);
6462            let live = app.canvas.viewport();
6463            let clear = app.safe.region();
6464            let bar = crate::shell::berth_rect(&ctx, crate::shell::Berth::TopBar)
6465                .expect("the top bar never laid out");
6466            assert!(live.is_positive(), "precondition: the canvas laid out");
6467            assert!(
6468                clear.top() >= bar.bottom(),
6469                "the safe region at {clear:?} runs under the bar at {bar:?}",
6470            );
6471
6472            app.dispatch_action(&ctx, Action::ViewRev(rev(2)));
6473            super::shell_frames(&mut app, &ctx, screen(), 4);
6474            assert_eq!(
6475                crate::shell::berth_rect(&ctx, crate::shell::Berth::TopBar),
6476                Some(bar),
6477                "the mode resized the bar instead of filling its centre",
6478            );
6479            assert_eq!(app.canvas.viewport(), live, "the lens reflowed the canvas");
6480            assert_eq!(
6481                app.safe.region(),
6482                clear,
6483                "the lens moved the region the drawing frames inside",
6484            );
6485        }
6486
6487        /// Escape returns to the present from anywhere (spec §3.2) — driven
6488        /// as a real key event through a real frame, so a handler that never
6489        /// sees the key fails here.
6490        #[test]
6491        fn escape_returns_to_the_present_from_anywhere() {
6492            let ctx = super::shell_ctx();
6493            let mut app = app_with_three_revs();
6494            app.dispatch_action(&ctx, Action::ViewRev(rev(2)));
6495            super::shell_frames(&mut app, &ctx, screen(), 4);
6496            assert_eq!(
6497                app.session.viewing(),
6498                Viewing::Past(rev(2)),
6499                "precondition: the lens is open",
6500            );
6501
6502            press(&mut app, &ctx, screen(), egui::Key::Escape);
6503            assert_eq!(
6504                app.session.viewing(),
6505                Viewing::Head,
6506                "Escape did not close the lens",
6507            );
6508        }
6509
6510        /// §8's ordering, which is the whole of why the navigator claims the
6511        /// key: Escape closes the panel *before* anything else reads it, and
6512        /// the next press is the lens's. Two presses, two effects, in the
6513        /// spec's order.
6514        #[test]
6515        fn escape_closes_the_navigator_before_it_closes_the_lens() {
6516            use crate::shell::workspace::PanelView;
6517            let ctx = super::shell_ctx();
6518            let mut app = app_with_three_revs();
6519            app.workspace.show(PanelView::History);
6520            app.dispatch_action(&ctx, Action::ViewRev(rev(2)));
6521            super::shell_frames(&mut app, &ctx, screen(), 4);
6522            assert!(
6523                app.workspace.open() && app.session.viewing() == Viewing::Past(rev(2)),
6524                "precondition: the panel is up over an open lens",
6525            );
6526
6527            press(&mut app, &ctx, screen(), egui::Key::Escape);
6528            assert!(!app.workspace.open(), "Escape left the panel standing");
6529            assert_eq!(
6530                app.session.viewing(),
6531                Viewing::Past(rev(2)),
6532                "the same press closed the lens as well as the panel",
6533            );
6534
6535            press(&mut app, &ctx, screen(), egui::Key::Escape);
6536            assert_eq!(
6537                app.session.viewing(),
6538                Viewing::Head,
6539                "the second press did not reach the lens",
6540            );
6541        }
6542
6543        /// One shell frame in which `key` is pressed and released.
6544        fn press(app: &mut App, ctx: &egui::Context, screen: Rect, key: egui::Key) {
6545            ctx.clone()
6546                .run_ui(
6547                    egui::RawInput {
6548                        screen_rect: Some(screen.egui()),
6549                        events: vec![egui::Event::Key {
6550                            key,
6551                            physical_key: None,
6552                            pressed: true,
6553                            repeat: false,
6554                            modifiers: egui::Modifiers::NONE,
6555                        }],
6556                        ..Default::default()
6557                    },
6558                    |ui| app.shell_frame(ui),
6559                )
6560                .drop_without_applying_deltas();
6561        }
6562
6563        /// A session with band 2's second tool armed, through the digit that
6564        /// arms it — the real path, so a slot that only fills when a test
6565        /// assigns the field fails here.
6566        fn app_holding_the_block_tool(ctx: &egui::Context) -> App {
6567            use crate::tools::names::ToolName;
6568            let mut app = app_with_three_revs();
6569            press(&mut app, ctx, screen(), egui::Key::Num2);
6570            super::shell_frames(&mut app, ctx, screen(), 2);
6571            assert_eq!(
6572                app.session.tool.name(),
6573                ToolName::NewBlock,
6574                "precondition: the digit armed the cluster's second tool",
6575            );
6576            app
6577        }
6578
6579        /// The mode answers to the lens and to nothing else: arming a tool
6580        /// leaves the bar's centre empty — nothing shifts under the user's
6581        /// hand when a tool is picked — and only a past rev on the canvas
6582        /// fills it. Walked through a whole session, so a frame that fills
6583        /// the centre on the wrong condition or leaves it filled fails here.
6584        #[test]
6585        fn only_the_lens_fills_the_bars_centre() {
6586            let ctx = super::shell_ctx();
6587            let mut app = app_holding_the_block_tool(&ctx);
6588            let showing = |app: &mut App| {
6589                super::shell_frames(app, &ctx, screen(), 4);
6590                let said = super::shell_text(app, &ctx, screen());
6591                let up = said.iter().any(|word| word.starts_with("Rev "));
6592                (up, said)
6593            };
6594
6595            let (up, said) = showing(&mut app);
6596            assert!(!up, "an armed tool filled the bar's centre: {said:?}");
6597
6598            app.dispatch_action(&ctx, Action::ViewRev(rev(2)));
6599            let (up, said) = showing(&mut app);
6600            assert!(up, "the lens did not fill the centre: {said:?}");
6601
6602            app.dispatch_action(&ctx, Action::ViewHead);
6603            let (up, said) = showing(&mut app);
6604            assert!(!up, "the centre outlived the lens: {said:?}");
6605        }
6606
6607        /// Tagging from the panel is not an edit: it reaches the tags and
6608        /// nothing else — not the log's revs, not the undo stack — so
6609        /// Ctrl+Z after naming a rev still takes back the last edit.
6610        #[test]
6611        fn tagging_a_rev_moves_neither_the_log_nor_the_undo_stack() {
6612            let ctx = egui::Context::default();
6613            let mut app = app_with_three_revs();
6614            let before = head(&app);
6615            let at = app.session.doc.repo().rev();
6616            let depth = app.session.doc.trail().undo_depth();
6617
6618            // Tagging is offered while *viewing* the past, which is where
6619            // the panel is used from — and viewing withholds every write.
6620            app.dispatch_action(&ctx, Action::ViewRev(rev(1)));
6621            app.dispatch_action(
6622                &ctx,
6623                Action::TagRev {
6624                    at: rev(1),
6625                    name: "Initial Draft".to_owned(),
6626                    how: blockworx_store::tags::Tagging::Added,
6627                },
6628            );
6629
6630            assert_eq!(app.session.doc.tags().of(rev(1)), ["Initial Draft"]);
6631            assert_eq!(app.session.doc.repo().rev(), at, "a tag spent a rev");
6632            assert_eq!(app.session.doc.trail().undo_depth(), depth);
6633            assert_eq!(head(&app), before, "a tag changed the document");
6634
6635            // §8.1's rev carries a set, so a second name joins the first
6636            // rather than replacing it.
6637            app.dispatch_action(
6638                &ctx,
6639                Action::TagRev {
6640                    at: rev(1),
6641                    name: "vendor".to_owned(),
6642                    how: blockworx_store::tags::Tagging::Added,
6643                },
6644            );
6645            assert_eq!(
6646                app.session.doc.tags().of(rev(1)),
6647                ["Initial Draft", "vendor"]
6648            );
6649
6650            app.dispatch_action(
6651                &ctx,
6652                Action::TagRev {
6653                    at: rev(1),
6654                    name: "Initial Draft".to_owned(),
6655                    how: blockworx_store::tags::Tagging::Removed,
6656                },
6657            );
6658            assert_eq!(
6659                app.session.doc.tags().of(rev(1)),
6660                ["vendor"],
6661                "an untag took off more than the name it gave",
6662            );
6663        }
6664
6665        /// The cluster's two step buttons, through the policy both they and
6666        /// the dispatch read: bounded at the oldest rev, and off the end of
6667        /// the log into the writable present.
6668        #[test]
6669        fn stepping_walks_back_through_the_log_and_forward_into_the_present() {
6670            use blockworx_store::doc::{At, TimeStep};
6671
6672            let ctx = egui::Context::default();
6673            let mut app = app_with_three_revs();
6674            let head = app.session.doc.repo().rev();
6675            let step = |app: &App, dir| app.session.viewing().stepped(head, dir);
6676
6677            app.dispatch_action(&ctx, Action::ViewRev(rev(2)));
6678            assert_eq!(step(&app, TimeStep::Back), Some(At::Rev(rev(1))));
6679
6680            app.dispatch_action(&ctx, Action::ViewRev(rev(1)));
6681            assert_eq!(
6682                step(&app, TimeStep::Back),
6683                None,
6684                "there is nothing before the first rev",
6685            );
6686            assert_eq!(step(&app, TimeStep::Forward), Some(At::Rev(rev(2))));
6687
6688            app.dispatch_action(&ctx, Action::ViewRev(rev(3)));
6689            assert_eq!(
6690                step(&app, TimeStep::Forward),
6691                Some(At::Current),
6692                "forward off the end of the log is the writable present",
6693            );
6694            app.dispatch_action(&ctx, Action::ViewHead);
6695            assert_eq!(step(&app, TimeStep::Back), None, "the present is not a rev");
6696        }
6697
6698        /// A container this session may not write can still be walked
6699        /// through — viewing costs nothing — and the walk changes nothing
6700        /// about what it may write, which under R37 is nothing at all.
6701        #[cfg(not(target_arch = "wasm32"))]
6702        #[test]
6703        fn a_read_only_container_walks_its_own_past() {
6704            use blockworx_store::handle::{Clock, Store};
6705            use blockworx_store::record::Identity;
6706            use blockworx_store::temp::TempDir;
6707
6708            let ctx = egui::Context::default();
6709            let dir = TempDir::new("app-time-machine-read-only");
6710            let root = dir.join("doc.bwx");
6711            let mut store = Store::create(&root, Clock::System).expect("the container");
6712            for (n, ops) in [
6713                (1, vec![fx::block(1, 0.0), fx::top(1)]),
6714                (2, vec![blockworx_store::fixture::block_move(1, 5)]),
6715            ] {
6716                store
6717                    .submit_edit(
6718                        Commit::new(format!("Edit {n}"), ops),
6719                        &Identity::new("test"),
6720                    )
6721                    .expect("the edit lands");
6722            }
6723            drop(store);
6724
6725            let _held = Store::open(&root, Clock::System).expect("the first session holds it");
6726            let mut app = App::new(super::AppConfig {
6727                opening: crate::app::Opening::Path(root.clone()),
6728                ..Default::default()
6729            });
6730            assert!(
6731                app.session.doc.read_only_reason().is_some(),
6732                "precondition: the container is locked by another session",
6733            );
6734
6735            app.dispatch_action(&ctx, Action::ViewRev(rev(1)));
6736            assert_eq!(
6737                app.session.viewing(),
6738                Viewing::Past(rev(1)),
6739                "viewing is free"
6740            );
6741            let set = commands(&mut app);
6742            assert!(
6743                set.contains(CommandId::Export(crate::export::ExportFormat::Json)),
6744                "a reader may not take the rev it is looking at out of the log",
6745            );
6746            assert!(
6747                !set.contains(CommandId::Delete),
6748                "a container this session may not write offered an edit",
6749            );
6750
6751            // And the door itself refuses, not merely the registry.
6752            let before = app.session.doc.repo().rev();
6753            app.dispatch_action(&ctx, Action::ViewHead);
6754            app.session.submit(Commit::new(
6755                "An edit this session may not make".into(),
6756                vec![blockworx_store::fixture::block_move(1, 9)],
6757            ));
6758            assert_eq!(
6759                app.session.doc.repo().rev(),
6760                before,
6761                "a read-only container took an edit anyway",
6762            );
6763        }
6764    }
6765
6766    // ── Provenance exports (D19) ─────────────────────────────────────────────
6767    //
6768    // One artifact, three consumers. Everything here drives the real
6769    // dispatch: the export is the action the history row fires, the paste is
6770    // the action the keyboard fires, and the clipboard between them is the
6771    // `CopyText` command the app actually emitted.
6772
6773    mod provenance {
6774        use super::{Action, App, copied};
6775        use crate::tools::tool::ExportTo;
6776        use crate::widget::test_fixtures as fx;
6777        use blockworx_doc::{commit::Commit, fixtures::rev, id::BlockId, rev::Rev};
6778        use blockworx_geom::{Rect, pos2};
6779        use blockworx_store::projection::{Found, Provenance, stamp_in};
6780
6781        /// Three revs over one child block, the block moving each time, so
6782        /// rev 1 differs visibly from rev 3.
6783        fn app_with_three_revs() -> App {
6784            let mut app = super::app_on(vec![
6785                fx::block(1, 0.0),
6786                fx::block_in(
6787                    2,
6788                    crate::path::Scope::Block(blockworx_doc::fixtures::block_id(1)),
6789                    Rect::from_min_max(pos2(4.0, 4.0), pos2(24.0, 24.0)),
6790                ),
6791                fx::top(1),
6792            ]);
6793            for x in [5, 9] {
6794                app.session.submit(Commit::new(
6795                    format!("Moved to {x}"),
6796                    vec![blockworx_store::fixture::block_move(2, x)],
6797                ));
6798            }
6799            assert_eq!(
6800                app.session.doc.repo().rev(),
6801                rev(3),
6802                "precondition: three revs"
6803            );
6804            app
6805        }
6806
6807        /// What the history row's Copy put on the clipboard.
6808        fn copy_rev(app: &mut App, ctx: &egui::Context, at: Rev) -> String {
6809            app.dispatch_action(
6810                ctx,
6811                Action::ExportRev {
6812                    at,
6813                    to: ExportTo::Clipboard,
6814                },
6815            );
6816            copied(ctx).expect("the rev reached the clipboard")
6817        }
6818
6819        fn provenance_of(text: &str) -> Provenance {
6820            match stamp_in(text) {
6821                Found::Stamped(stamp) => stamp.provenance.expect("the export carries provenance"),
6822                found => panic!("the export carries no stamp this build reads: {found:?}"),
6823            }
6824        }
6825
6826        /// The blocks a scope holds, in the head document.
6827        fn children_of(app: &App, parent: BlockId) -> Vec<BlockId> {
6828            app.session
6829                .doc
6830                .repo()
6831                .document()
6832                .blocks()
6833                .filter(|(_, block)| block.parent == parent)
6834                .map(|(id, _)| id)
6835                .collect()
6836        }
6837
6838        /// The blocks in the level the canvas is open on — the *active
6839        /// scope*, which is where D19 says an insert lands.
6840        fn blocks_in_scope(app: &App) -> Vec<BlockId> {
6841            children_of(app, app.session.path.scope().wire_id())
6842        }
6843
6844        /// D19: an export taken at a rev names *that* rev, the name the rev
6845        /// carries (D18), and the session that took it — and the document
6846        /// under the stamp is that rev's fold, not the head's.
6847        #[test]
6848        fn an_export_at_a_rev_is_stamped_with_that_rev_its_tag_and_this_session() {
6849            let ctx = egui::Context::default();
6850            let mut app = app_with_three_revs();
6851            app.dispatch_action(
6852                &ctx,
6853                Action::TagRev {
6854                    at: rev(2),
6855                    name: "Initial Draft".to_owned(),
6856                    how: blockworx_store::tags::Tagging::Added,
6857                },
6858            );
6859            assert_eq!(
6860                app.session.doc.tags().of(rev(2)),
6861                ["Initial Draft"],
6862                "precondition: rev 2 is tagged",
6863            );
6864
6865            let text = copy_rev(&mut app, &ctx, rev(2));
6866            let from = provenance_of(&text);
6867            assert_eq!(from.rev, rev(2));
6868            assert_eq!(from.author, app.session.identity.name);
6869            assert_eq!(from.tags, ["Initial Draft"]);
6870            assert_eq!(from.line(), format!("Rev 2 of {}", app.document_name()));
6871
6872            let at_two = app.session.document_at(rev(2)).expect("rev 2 reads back");
6873            assert_eq!(
6874                blockworx_store::document_file::parse(&text, "the export").expect("it parses"),
6875                at_two,
6876                "the export is not the document at the rev it names",
6877            );
6878            assert_ne!(
6879                blockworx_store::document_file::to_json(&at_two),
6880                blockworx_store::document_file::to_json(app.session.doc.repo().document()),
6881                "precondition: rev 2 and the head are different documents",
6882            );
6883
6884            // The head's own export names the head, with no tag it has none of.
6885            let head = provenance_of(&copy_rev(&mut app, &ctx, rev(3)));
6886            assert_eq!(head.rev, rev(3));
6887            assert!(head.tags.is_empty());
6888        }
6889
6890        /// The loop D19 exists for: copy a rev out of the history, paste it
6891        /// into the writable present, and the old document is standing there
6892        /// as one block holding what it held then.
6893        #[test]
6894        fn copying_a_rev_and_pasting_it_at_head_inserts_it_as_one_block() {
6895            let ctx = egui::Context::default();
6896            let mut app = app_with_three_revs();
6897            let before = blocks_in_scope(&app);
6898            assert_eq!(before.len(), 1, "precondition: one block in the open level");
6899
6900            let scope = app.session.scope_name();
6901            assert!(
6902                !scope.is_empty(),
6903                "precondition: the canvas is inside a level"
6904            );
6905            let undoable = app.session.doc.trail().undo_depth();
6906            let text = copy_rev(&mut app, &ctx, rev(2));
6907            app.dispatch_action(&ctx, Action::Paste(text));
6908
6909            let after = blocks_in_scope(&app);
6910            assert_eq!(
6911                after.len(),
6912                2,
6913                "the pasted document did not land as one new block in the open scope",
6914            );
6915            let inserted = *after
6916                .iter()
6917                .find(|id| !before.contains(id))
6918                .expect("a new block in the open scope");
6919            assert_eq!(
6920                app.session
6921                    .doc
6922                    .repo()
6923                    .document()
6924                    .block(&inserted)
6925                    .expect("the inserted block")
6926                    .title
6927                    .name
6928                    .as_str(),
6929                app.document_name(),
6930                "the inserted block is not titled after the source document",
6931            );
6932            assert_eq!(
6933                children_of(&app, inserted).len(),
6934                1,
6935                "the source document's top-level blocks are not the new block's children",
6936            );
6937
6938            let label = app
6939                .session
6940                .doc
6941                .repo()
6942                .log()
6943                .last()
6944                .expect("the insert sealed a commit")
6945                .label()
6946                .to_owned();
6947            assert_eq!(
6948                label,
6949                format!(
6950                    "Insert diagram {} at rev 2 from {} into scope {scope}",
6951                    app.document_name(),
6952                    app.session.identity.name,
6953                ),
6954                "the insert did not land under D19's label",
6955            );
6956            assert_eq!(
6957                app.session.doc.trail().undo_depth(),
6958                undoable + 1,
6959                "the insert is an ordinary gesture and must stand ready to be undone",
6960            );
6961        }
6962
6963        /// A plain `document.json` carries no provenance, so it is named by
6964        /// the file it arrived in — and it still inserts as a block.
6965        #[test]
6966        fn a_document_with_no_provenance_inserts_under_its_file_stem() {
6967            let mut app = app_with_three_revs();
6968            let json = blockworx_store::document_file::to_json(app.session.doc.repo().document());
6969            assert!(
6970                !json.contains("stamp"),
6971                "precondition: a bare projection carries no stamp",
6972            );
6973            let before = blocks_in_scope(&app);
6974            let scope = app.session.scope_name();
6975
6976            app.session
6977                .handle_imported("motor-controller.json", json.into_bytes());
6978
6979            assert_eq!(blocks_in_scope(&app).len(), before.len() + 1);
6980            assert_eq!(
6981                app.session
6982                    .doc
6983                    .repo()
6984                    .log()
6985                    .last()
6986                    .expect("the import sealed a commit")
6987                    .label(),
6988                format!("Insert diagram motor-controller into scope {scope}"),
6989                "an unprovenanced document is named by its file, and says no rev it has none of",
6990            );
6991        }
6992
6993        /// Taking a copy is reading, so a session that may not write does it;
6994        /// pasting one back is writing, so the same session does not.
6995        #[test]
6996        fn a_read_only_session_may_copy_a_rev_but_not_paste_one() {
6997            let ctx = egui::Context::default();
6998            let mut app = app_with_three_revs();
6999            let text = copy_rev(&mut app, &ctx, rev(2));
7000
7001            app.dispatch_action(&ctx, Action::ViewRev(rev(2)));
7002            assert_eq!(
7003                app.session.may_write(),
7004                blockworx_store::doc::Writability::ReadOnly,
7005                "precondition: the time machine makes the session read-only",
7006            );
7007
7008            assert!(
7009                !copy_rev(&mut app, &ctx, rev(1)).is_empty(),
7010                "a read-only session was refused a copy it writes nothing to make",
7011            );
7012
7013            let head = app.session.doc.repo().rev();
7014            app.dispatch_action(&ctx, Action::Paste(text));
7015            assert_eq!(
7016                app.session.doc.repo().rev(),
7017                head,
7018                "a read-only session pasted a document into the log",
7019            );
7020        }
7021
7022        /// The two clipboard payloads are disjoint, read off the real copy
7023        /// paths: a copied selection is never taken for a document, a copied
7024        /// rev is never taken for a selection, and the canvas claims both
7025        /// from a focused text box.
7026        #[test]
7027        fn a_copied_selection_and_a_copied_rev_cannot_be_taken_for_each_other() {
7028            use crate::shape::ShapeId;
7029            use crate::widget::clipboard::is_object_clipboard;
7030            let ctx = egui::Context::default();
7031            let mut app = app_with_three_revs();
7032
7033            let shape = ShapeId::Rect(blocks_in_scope(&app)[0]);
7034            app.dispatch_action(&ctx, Action::Copy(vec![shape]));
7035            let selection = copied(&ctx).expect("the selection reached the clipboard");
7036            assert!(
7037                crate::edit::clipboard::Clipboard::from_json(&selection).is_some(),
7038                "precondition: a copied selection is a clipboard payload",
7039            );
7040            assert!(blockworx_editor::import::from_clipboard(&selection).is_none());
7041
7042            let export = copy_rev(&mut app, &ctx, rev(2));
7043            assert!(blockworx_editor::import::from_clipboard(&export).is_some());
7044            assert!(
7045                crate::edit::clipboard::Clipboard::from_json(&export).is_none(),
7046                "an exported document was read as a copied selection",
7047            );
7048
7049            for payload in [&selection, &export] {
7050                assert!(
7051                    is_object_clipboard(payload),
7052                    "the canvas would have let this fall into a text box",
7053                );
7054            }
7055            assert!(!is_object_clipboard("just some text"));
7056        }
7057
7058        /// The title block says where an opened export came from, and an
7059        /// ordinary session says nothing at all.
7060        ///
7061        /// Driven through the command line's door, which is the only one:
7062        /// nothing in the chrome opens a `document.json`, because it is a
7063        /// projection and not a diagram (R53).
7064        #[cfg(not(target_arch = "wasm32"))]
7065        #[test]
7066        fn opening_an_export_shows_where_it_came_from() {
7067            let ctx = egui::Context::default();
7068            let dir = blockworx_store::temp::TempDir::new("opened-export");
7069            let mut app = app_with_three_revs();
7070            assert!(
7071                app.title_block()
7072                    .grid(&[])
7073                    .cells()
7074                    .all(|cell| cell.caption != "From:"),
7075                "precondition: a document authored here came from nowhere",
7076            );
7077
7078            let source = app.document_name();
7079            let text = copy_rev(&mut app, &ctx, rev(2));
7080            let path = dir.join("shared.json");
7081            std::fs::write(&path, &text).expect("the export is written");
7082            let app = App::new(crate::app::AppConfig {
7083                opening: crate::app::Opening::Path(path.clone()),
7084                ..crate::app::AppConfig::default()
7085            });
7086
7087            let grid = app.title_block().grid(&[]);
7088            let from = grid
7089                .cells()
7090                .find(|cell| cell.caption == "From:")
7091                .expect("the title block does not say where the document came from");
7092            assert_eq!(from.value.text(), format!("Rev 2 of {source}"));
7093            assert_eq!(
7094                app.document_name(),
7095                "shared",
7096                "the session is now its own document, named by the file it opened",
7097            );
7098            assert_eq!(
7099                app.title_block().rev,
7100                app.session.doc.repo().rev(),
7101                "the Rev row names this session's rev, not the source's",
7102            );
7103        }
7104    }
7105
7106    // ── The container flow ───────────────────────────────────────────────────
7107    //
7108    // Everything here drives the real dispatch and the real registry against
7109    // a container in a temp directory. Nothing drives a dialog: `rfd` is a
7110    // path delivery mechanism, and what it delivers to is
7111    // `crate::file`'s plain functions, tested there.
7112
7113    #[cfg(not(target_arch = "wasm32"))]
7114    mod containers {
7115        use super::{App, AppConfig, BlockPath};
7116        use crate::history::Direction;
7117        use crate::panels::notices::Notice;
7118        use crate::tools::commands::CommandId;
7119        use crate::tools::tool::Action;
7120        use crate::widget::test_fixtures as fx;
7121        use blockworx_doc::{
7122            commit::Commit,
7123            fixtures::{block_id, rev},
7124        };
7125        use blockworx_geom::{Rect, pos2};
7126        use blockworx_store::doc::Doc;
7127        use blockworx_store::doc::{Viewing, Writability};
7128        use blockworx_store::handle::{Clock, Store};
7129        use blockworx_store::manifest::{Row, RowKind};
7130        use blockworx_store::record::Identity;
7131        use blockworx_store::temp::TempDir;
7132        use std::path::Path;
7133
7134        fn app_opening(path: &Path) -> App {
7135            App::new(AppConfig {
7136                opening: crate::app::Opening::Path(path.to_path_buf()),
7137                ..Default::default()
7138            })
7139        }
7140
7141        /// A session started with no path, with the documents directory
7142        /// pointed at `documents` — D20's born-attached start, driven
7143        /// exactly as `main` drives it minus the platform lookup.
7144        fn app_born_in(documents: &Path) -> App {
7145            App::new(AppConfig {
7146                opening: crate::app::Opening::Born,
7147                documents: blockworx_store::naming::Documents::at(documents),
7148                ..Default::default()
7149            })
7150        }
7151
7152        /// The container a born session made, which is the only thing in
7153        /// its documents directory.
7154        fn only_container_in(documents: &Path) -> std::path::PathBuf {
7155            let mut found: Vec<_> = std::fs::read_dir(documents)
7156                .expect("the documents directory")
7157                .map(|entry| entry.expect("an entry").path())
7158                .collect();
7159            assert_eq!(found.len(), 1, "the documents directory holds {found:?}");
7160            found.pop().expect("the one container")
7161        }
7162
7163        /// The first edit a blank document can take, through the app's own
7164        /// write door — the door every gesture ends at, so what it proves
7165        /// about the log holds for the toolbar too.
7166        /// One edit, as a frame takes it: the session writes, and the frame
7167        /// that carried the write claims the container the record landed in.
7168        fn draw_a_block(app: &mut App) {
7169            app.session.submit(Commit::new(
7170                "Drew a block".to_owned(),
7171                vec![fx::block(1, 0.0), fx::top(1)],
7172            ));
7173            app.claim_if_written();
7174        }
7175
7176        /// A second edit, on the block the first one drew.
7177        fn move_the_block(app: &mut App) {
7178            app.session.submit(Commit::new(
7179                "Moved a block".to_owned(),
7180                vec![blockworx_store::fixture::block_move(1, 5)],
7181            ));
7182            app.claim_if_written();
7183        }
7184
7185        /// A container holding one block, closed — something an app can open
7186        /// and then edit.
7187        fn container_with_a_block(root: &Path) {
7188            let mut store = Store::create(root, Clock::System).expect("the container is laid out");
7189            store
7190                .submit_edit(
7191                    Commit::new("Built a scene".into(), vec![fx::block(1, 0.0), fx::top(1)]),
7192                    &Identity::new("test"),
7193                )
7194                .expect("the scene lands");
7195        }
7196
7197        fn records(root: &Path) -> Vec<Row> {
7198            std::fs::read_to_string(root.join(blockworx_store::container::MANIFEST))
7199                .expect("the manifest is readable")
7200                .lines()
7201                .map(|line| serde_json::from_str(line).expect("every row parses"))
7202                .collect()
7203        }
7204
7205        /// One frame's tail: dispatch, then close the history step, exactly
7206        /// as `App::ui` does — which is what makes the undo below the undo a
7207        /// user would get.
7208        fn frame(app: &mut App, ctx: &egui::Context, action: Action) {
7209            let before = app.session.state();
7210            app.dispatch_action(ctx, action);
7211            app.session
7212                .record_history(&before, core::time::Duration::ZERO);
7213        }
7214
7215        /// The fixture block's rect, read straight out of the document: the
7216        /// block *is* the scope the editor opens inside, so it is not one of
7217        /// the shapes drawn in that scope.
7218        fn block_rect(app: &App) -> blockworx_doc::geometry::GridRect {
7219            app.session
7220                .doc
7221                .document()
7222                .block(&block_id(1))
7223                .expect("the fixture's block")
7224                .rect
7225        }
7226
7227        fn select_the_block(app: &mut App) {
7228            app.session.tool = crate::tools::resize_block::ResizeBlock::Selected {
7229                shape: crate::shape::ShapeId::Rect(block_id(1)),
7230            }
7231            .into();
7232        }
7233
7234        #[test]
7235        fn a_container_path_argument_opens_it_attached_and_names_the_window() {
7236            let dir = TempDir::new("app-opens-a-container");
7237            let root = dir.join("doc.bwx");
7238            container_with_a_block(&root);
7239
7240            let app = app_opening(&root);
7241            assert!(
7242                matches!(app.session.doc, Doc::Attached { .. }),
7243                "a .bwx path must open the container, not a scratch session",
7244            );
7245            assert_eq!(app.window_title(), "doc.bwx - BlockWorx");
7246            assert_eq!(
7247                app.session.doc.projection(),
7248                Some(blockworx_store::projection::Freshness::Stale),
7249                "the fixture container has revs and no projection beside them — \
7250                 stale, but never a title marker (the revs are always current)",
7251            );
7252            assert_eq!(
7253                app.session.doc.repo().rev(),
7254                blockworx_doc::fixtures::rev(1),
7255                "the container's head is the rev it opened at",
7256            );
7257        }
7258
7259        /// D20's start: no path means a blank canvas in a container of its
7260        /// own, attached and writable from the first frame — not a demo, and
7261        /// not a session that persists nothing.
7262        #[test]
7263        fn a_start_with_no_path_is_born_attached_on_a_blank_canvas() {
7264            let dir = TempDir::new("app-born-attached");
7265            let documents = dir.join("Documents");
7266
7267            let app = app_born_in(&documents);
7268
7269            assert!(
7270                matches!(app.session.doc, Doc::Attached { .. }),
7271                "a no-argument start must be born attached, not scratch",
7272            );
7273            assert_eq!(
7274                app.session.doc.writability(),
7275                blockworx_store::doc::Writability::Writable,
7276                "a document born here must be writable from its first edit",
7277            );
7278            assert!(
7279                app.session.doc.repo().log().is_empty()
7280                    && app.session.doc.document().blocks().next().is_none(),
7281                "the canvas opened on something rather than blank",
7282            );
7283
7284            let root = only_container_in(&documents);
7285            assert_eq!(app.attached_root().as_deref(), Some(root.as_path()));
7286            let name = crate::file::document_name(&root);
7287            assert_eq!(
7288                name.split('-').count(),
7289                3,
7290                "{name} is not the three-word name D20 asks for",
7291            );
7292            assert_eq!(app.window_title(), format!("{name}.bwx - BlockWorx"));
7293        }
7294
7295        /// The F6 property, from rev 1: the very first edit of a session
7296        /// that opened on nothing is in a file when the call returns. It is
7297        /// also what claims the container — the recent list learns the name
7298        /// only once the document is worth reopening.
7299        #[test]
7300        fn the_first_edit_lands_in_the_born_container_and_claims_it() {
7301            let dir = TempDir::new("app-born-first-edit");
7302            let documents = dir.join("Documents");
7303            let mut app = app_born_in(&documents);
7304            let root = only_container_in(&documents);
7305            assert!(
7306                records(&root).is_empty() && !app.recent.paths().contains(&root),
7307                "precondition: nothing written, nothing remembered",
7308            );
7309
7310            draw_a_block(&mut app);
7311
7312            let written = records(&root);
7313            assert_eq!(written.len(), 1, "the first edit never reached the file");
7314            assert_eq!(written[0].kind, RowKind::Edit);
7315            assert_eq!(written[0].rev, app.session.doc.repo().rev());
7316            assert!(
7317                app.recent.paths().contains(&root),
7318                "a written-in container did not join the recent list",
7319            );
7320        }
7321
7322        /// Launch, quit, and the documents directory is as it was: a
7323        /// container this session invented and nobody wrote in goes with it.
7324        #[test]
7325        fn quitting_a_session_that_wrote_nothing_removes_the_container_it_made() {
7326            use eframe::App as _;
7327
7328            let dir = TempDir::new("app-born-pristine-exit");
7329            let documents = dir.join("Documents");
7330            let mut app = app_born_in(&documents);
7331            let root = only_container_in(&documents);
7332
7333            app.on_exit();
7334
7335            assert!(!root.exists(), "quitting left {} behind", root.display());
7336            assert!(
7337                std::fs::read_dir(&documents)
7338                    .expect("the documents directory")
7339                    .next()
7340                    .is_none(),
7341                "the documents directory was littered",
7342            );
7343        }
7344
7345        /// The other half of the rule: a container with an edit in it is a
7346        /// document, and quitting does not touch it.
7347        #[test]
7348        fn quitting_after_an_edit_keeps_the_container() {
7349            use eframe::App as _;
7350
7351            let dir = TempDir::new("app-born-written-exit");
7352            let documents = dir.join("Documents");
7353            let mut app = app_born_in(&documents);
7354            let root = only_container_in(&documents);
7355            draw_a_block(&mut app);
7356
7357            app.on_exit();
7358
7359            assert_eq!(records(&root).len(), 1, "the edit is still in the log");
7360            assert!(
7361                root.join(blockworx_store::container::PROJECTION).is_file(),
7362                "the clean exit did not leave the projection beside it",
7363            );
7364        }
7365
7366        /// Only what this session created is ever swept. A container the
7367        /// user opened is theirs, however empty — deleting it is not ours to
7368        /// decide.
7369        #[test]
7370        fn a_container_the_session_only_opened_is_never_removed() {
7371            use eframe::App as _;
7372
7373            let dir = TempDir::new("app-opened-empty-kept");
7374            let root = dir.join("theirs.bwx");
7375            drop(Store::create(&root, Clock::System).expect("their empty container"));
7376
7377            let mut app = app_opening(&root);
7378            assert!(
7379                matches!(app.session.doc, Doc::Attached { .. }) && records(&root).is_empty(),
7380                "precondition: an empty container, opened rather than created",
7381            );
7382
7383            app.on_exit();
7384
7385            assert!(
7386                root.join(blockworx_store::container::MANIFEST).is_file(),
7387                "an empty container the user opened was swept away",
7388            );
7389        }
7390
7391        /// File ▸ New twice leaves one container, not two: the one nobody
7392        /// wrote in goes as soon as its store is dropped.
7393        #[test]
7394        fn a_second_new_document_takes_the_first_pristine_one_with_it() {
7395            let ctx = egui::Context::default();
7396            let dir = TempDir::new("app-new-twice");
7397            let documents = dir.join("Documents");
7398            let mut app = app_born_in(&documents);
7399            let first = only_container_in(&documents);
7400
7401            app.dispatch_action(&ctx, Action::NewDocument);
7402
7403            let second = only_container_in(&documents);
7404            assert_ne!(second, first, "File ▸ New reopened the same container");
7405            assert_eq!(
7406                app.attached_root().as_deref(),
7407                Some(second.as_path()),
7408                "the session is not on the container it just made",
7409            );
7410        }
7411
7412        /// Renaming the document renames its container, and the session
7413        /// carries straight on into it: the log it is appending to is held
7414        /// open across the move, lock and all.
7415        #[test]
7416        fn renaming_moves_the_container_and_the_edits_follow_it() {
7417            let ctx = egui::Context::default();
7418            let dir = TempDir::new("app-rename");
7419            let documents = dir.join("Documents");
7420            let mut app = app_born_in(&documents);
7421            let born = only_container_in(&documents);
7422            draw_a_block(&mut app);
7423            assert!(
7424                app.recent.paths().contains(&born),
7425                "precondition: the born container is written in and remembered",
7426            );
7427
7428            app.dispatch_action(&ctx, Action::RenameDocument("motor-controller".to_owned()));
7429
7430            let renamed = documents.join("motor-controller.bwx");
7431            assert!(!born.exists(), "the old name still stands");
7432            assert_eq!(app.attached_root().as_deref(), Some(renamed.as_path()));
7433            assert_eq!(app.document_name(), "motor-controller");
7434            assert_eq!(app.window_title(), "motor-controller.bwx - BlockWorx");
7435            assert_eq!(
7436                app.recent.paths().first(),
7437                Some(&renamed),
7438                "the recent list still offers the name it had: {:?}",
7439                app.recent.paths(),
7440            );
7441            assert!(app.notices().is_empty(), "a rename that worked complained");
7442
7443            move_the_block(&mut app);
7444            assert_eq!(
7445                records(&renamed).len(),
7446                2,
7447                "the edit after the rename did not land in the renamed log",
7448            );
7449        }
7450
7451        /// A rename that would land on something already there is refused,
7452        /// out loud, and leaves the session exactly where it was.
7453        #[test]
7454        fn renaming_onto_a_name_that_exists_is_refused() {
7455            let ctx = egui::Context::default();
7456            let dir = TempDir::new("app-rename-onto");
7457            let documents = dir.join("Documents");
7458            let mut app = app_born_in(&documents);
7459            let born = only_container_in(&documents);
7460            draw_a_block(&mut app);
7461            let taken = documents.join("taken.bwx");
7462            drop(Store::create(&taken, Clock::System).expect("the container in the way"));
7463
7464            app.dispatch_action(&ctx, Action::RenameDocument("taken".to_owned()));
7465
7466            assert!(
7467                matches!(app.notices().as_slice(), [Notice::Failure(_)]),
7468                "a refused rename said nothing the user can see",
7469            );
7470            assert_eq!(
7471                app.attached_root().as_deref(),
7472                Some(born.as_path()),
7473                "the session moved anyway",
7474            );
7475            assert_eq!(
7476                records(&taken).len(),
7477                0,
7478                "the container in the way was written"
7479            );
7480            move_the_block(&mut app);
7481            assert_eq!(records(&born).len(), 2, "the session stopped writing");
7482        }
7483
7484        /// A name that is no name — blank, or one that would move the
7485        /// document somewhere else — is refused before the filesystem is
7486        /// asked.
7487        #[test]
7488        fn a_rename_to_something_that_is_not_a_name_is_refused() {
7489            let ctx = egui::Context::default();
7490            let dir = TempDir::new("app-rename-nonsense");
7491            let documents = dir.join("Documents");
7492            let mut app = app_born_in(&documents);
7493            let born = only_container_in(&documents);
7494
7495            for nonsense in ["", "   ", "../escape", "sub/engine"] {
7496                app.dispatch_action(&ctx, Action::RenameDocument(nonsense.to_owned()));
7497                assert_eq!(
7498                    app.attached_root().as_deref(),
7499                    Some(born.as_path()),
7500                    "{nonsense:?} was taken for a document name",
7501                );
7502            }
7503            assert_eq!(app.notices().len(), 4, "each refusal is said once");
7504        }
7505
7506        #[test]
7507        fn an_edit_through_the_dispatch_lands_in_the_log_file() {
7508            let dir = TempDir::new("app-edit-reaches-the-log");
7509            let root = dir.join("doc.bwx");
7510            container_with_a_block(&root);
7511            let ctx = egui::Context::default();
7512
7513            let mut app = app_opening(&root);
7514            app.session.path = BlockPath::opening(app.session.doc.document());
7515            select_the_block(&mut app);
7516            assert_eq!(records(&root).len(), 1, "precondition: one seeded record");
7517
7518            frame(&mut app, &ctx, Action::Nudge { dx: 1, dy: 0 });
7519
7520            let written = records(&root);
7521            assert_eq!(written.len(), 2, "the nudge never reached the file");
7522            assert_eq!(written[1].kind, RowKind::Edit);
7523            assert_eq!(written[1].rev, app.session.doc.repo().rev());
7524            assert_eq!(
7525                written[1].author.name, app.session.identity.name,
7526                "the record is attributed to this session's identity (D9)",
7527            );
7528        }
7529
7530        /// Undo is a forward commit, so it is in the trail rather than an
7531        /// erasure of it (F7) — and the `of` each record names is what
7532        /// replay rebuilds the trail's stack movements from (F6).
7533        #[test]
7534        fn undo_and_redo_are_written_as_their_own_records() {
7535            let dir = TempDir::new("app-history-records");
7536            let root = dir.join("doc.bwx");
7537            container_with_a_block(&root);
7538            let ctx = egui::Context::default();
7539
7540            let mut app = app_opening(&root);
7541            app.session.path = BlockPath::opening(app.session.doc.document());
7542            select_the_block(&mut app);
7543
7544            frame(&mut app, &ctx, Action::Nudge { dx: 1, dy: 0 });
7545            let edit = app.session.doc.repo().rev();
7546            assert!(
7547                app.session.has_step(Direction::Back),
7548                "precondition: a step to take"
7549            );
7550
7551            frame(&mut app, &ctx, Action::Undo);
7552            frame(&mut app, &ctx, Action::Redo);
7553
7554            let kinds: Vec<RowKind> = records(&root).iter().map(|r| r.kind).collect();
7555            assert_eq!(
7556                kinds,
7557                [
7558                    RowKind::Edit,
7559                    RowKind::Edit,
7560                    RowKind::Undo { of: edit },
7561                    RowKind::Redo { of: edit.next() },
7562                ],
7563            );
7564        }
7565
7566        /// F6, end to end and through the real dispatch: a mistake is made,
7567        /// the projection is written, the session ends, and the next one
7568        /// takes the mistake back — with the undo in the trail as its own
7569        /// record, since the log is an audit trail rather than a state dump.
7570        #[test]
7571        fn a_mistake_is_still_undoable_after_a_save_a_quit_and_a_reopen() {
7572            let dir = TempDir::new("app-f6");
7573            let root = dir.join("doc.bwx");
7574            container_with_a_block(&root);
7575            let ctx = egui::Context::default();
7576
7577            let mut app = app_opening(&root);
7578            app.session.path = BlockPath::opening(app.session.doc.document());
7579            select_the_block(&mut app);
7580            let before = block_rect(&app);
7581
7582            frame(&mut app, &ctx, Action::Nudge { dx: 1, dy: 0 });
7583            let mistake = block_rect(&app);
7584            assert_ne!(before, mistake, "precondition: the nudge moved the block");
7585            app.save_projection();
7586            drop(app);
7587
7588            let mut app = app_opening(&root);
7589            assert!(
7590                app.session.has_step(Direction::Back),
7591                "a reopened container offered nothing to take back",
7592            );
7593            assert_eq!(
7594                app.session.state().stood,
7595                crate::history::Stood::of(app.session.doc.trail()),
7596                "the rebuilt editor stack does not stand where the trail does",
7597            );
7598            assert!(
7599                app.session
7600                    .available_commands(crate::edit::naming::InterfaceLock::Unlocked)
7601                    .contains(CommandId::Undo),
7602                "the registry withholds Undo on a document that has depth, so no \
7603                 chord, palette entry or toolbar button could reach it",
7604            );
7605
7606            frame(&mut app, &ctx, Action::Undo);
7607
7608            assert_eq!(
7609                block_rect(&app),
7610                before,
7611                "the mistake survived an undo taken after the reopen",
7612            );
7613            let written = records(&root);
7614            assert_eq!(written.len(), 3, "the undo did not reach the file");
7615            assert!(matches!(written[2].kind, RowKind::Undo { .. }));
7616        }
7617
7618        /// The other half of F6: a step taken back *before* the quit is
7619        /// still a step forward after it, and the depth the UI shows is the
7620        /// reconstructed one.
7621        #[test]
7622        fn an_undo_taken_before_the_quit_is_redoable_after_the_reopen() {
7623            let dir = TempDir::new("app-f6-redo");
7624            let root = dir.join("doc.bwx");
7625            container_with_a_block(&root);
7626            let ctx = egui::Context::default();
7627
7628            let mut app = app_opening(&root);
7629            app.session.path = BlockPath::opening(app.session.doc.document());
7630            select_the_block(&mut app);
7631            let before = block_rect(&app);
7632            frame(&mut app, &ctx, Action::Nudge { dx: 1, dy: 0 });
7633            let nudged = block_rect(&app);
7634            frame(&mut app, &ctx, Action::Undo);
7635            assert_eq!(block_rect(&app), before);
7636            drop(app);
7637
7638            let mut app = app_opening(&root);
7639            assert!(
7640                app.session.has_step(Direction::Forward),
7641                "the forward history did not survive the restart",
7642            );
7643            frame(&mut app, &ctx, Action::Redo);
7644            assert_eq!(
7645                block_rect(&app),
7646                nudged,
7647                "redoing after the restart did not put the edit back",
7648            );
7649            assert!(
7650                !app.session.has_step(Direction::Forward),
7651                "the future is spent"
7652            );
7653
7654            // And a fresh edit forks away from a reconstructed future the
7655            // same way it forks away from a live one.
7656            frame(&mut app, &ctx, Action::Undo);
7657            assert!(app.session.has_step(Direction::Forward));
7658            select_the_block(&mut app);
7659            frame(&mut app, &ctx, Action::Nudge { dx: 0, dy: 1 });
7660            assert!(
7661                !app.session.has_step(Direction::Forward),
7662                "a reconstructed redo outlived the edit that forked away from it",
7663            );
7664        }
7665
7666        /// F1: a second session on a held container may look, not write. The
7667        /// registry is what enforces it — the editing commands are not in
7668        /// the set at all, so nothing that reads the set (a chord, the
7669        /// palette, a script) can invoke one.
7670        #[test]
7671        fn a_read_only_container_withholds_the_editing_commands() {
7672            let dir = TempDir::new("app-read-only");
7673            let root = dir.join("doc.bwx");
7674            container_with_a_block(&root);
7675            let _held = Store::open(&root, Clock::System).expect("the first session holds it");
7676
7677            let mut app = app_opening(&root);
7678            app.session.path = BlockPath::opening(app.session.doc.document());
7679            select_the_block(&mut app);
7680            assert!(
7681                app.session.doc.read_only_reason().is_some(),
7682                "precondition: the lock is held, so this session may not write",
7683            );
7684            assert_eq!(app.window_title(), "doc.bwx - BlockWorx [read-only]");
7685
7686            let commands = app
7687                .session
7688                .available_commands(crate::edit::naming::InterfaceLock::Unlocked);
7689            for withheld in [
7690                CommandId::Delete,
7691                CommandId::Cut,
7692                CommandId::Undo,
7693                CommandId::Arm(crate::tools::names::ToolName::NewBlock),
7694            ] {
7695                assert!(
7696                    !commands.contains(withheld),
7697                    "{withheld:?} is invocable on a container this session may not write",
7698                );
7699            }
7700            for kept in [
7701                CommandId::Copy,
7702                CommandId::Export(crate::export::ExportFormat::Svg),
7703                CommandId::FitView,
7704            ] {
7705                assert!(commands.contains(kept), "{kept:?} writes nothing");
7706            }
7707        }
7708
7709        /// A read-only container refuses the writes that do not come through
7710        /// the registry either — the canvas still drags — and the refusal
7711        /// must not leave the editor's undo stack out of step with the
7712        /// trail.
7713        #[test]
7714        fn a_read_only_container_takes_no_edit_and_no_history_step() {
7715            let dir = TempDir::new("app-read-only-writes");
7716            let root = dir.join("doc.bwx");
7717            container_with_a_block(&root);
7718            let _held = Store::open(&root, Clock::System).expect("the first session holds it");
7719            let ctx = egui::Context::default();
7720
7721            let mut app = app_opening(&root);
7722            app.session.path = BlockPath::opening(app.session.doc.document());
7723            select_the_block(&mut app);
7724            let before = app.session.doc.document().clone();
7725
7726            frame(&mut app, &ctx, Action::Nudge { dx: 1, dy: 0 });
7727            frame(&mut app, &ctx, Action::Undo);
7728
7729            assert_eq!(
7730                app.session.doc.document().clone(),
7731                before,
7732                "a refused edit left the session's document ahead of the file",
7733            );
7734            assert_eq!(records(&root).len(), 1, "nothing was appended");
7735        }
7736
7737        /// Save As is how a session that has no file gets one: the container
7738        /// it lays out replays to exactly the document that was on screen.
7739        #[test]
7740        fn saving_a_scratch_session_as_a_container_replays_to_the_same_document() {
7741            let dir = TempDir::new("app-save-as");
7742            let root = dir.join("saved.bwx");
7743            let mut app = super::app_on(vec![fx::block(1, 0.0), fx::top(1)]);
7744            assert!(
7745                matches!(app.session.doc, Doc::Scratch { .. }),
7746                "precondition: nothing about this session is persisted",
7747            );
7748            let scratch = app.session.doc.document().clone();
7749            let commits = app.session.doc.repo().log().len();
7750
7751            app.save_as_container(
7752                &egui::Context::default(),
7753                &root,
7754                crate::file::SaveScope::Whole,
7755            );
7756
7757            assert_eq!(
7758                app.session.doc.container_root(),
7759                Some(root.as_path()),
7760                "the session did not adopt the container it just wrote",
7761            );
7762            assert_eq!(
7763                app.session.doc.projection(),
7764                Some(Freshness::Fresh),
7765                "Save As writes the projection, so a container is readable from \
7766                 the moment it exists",
7767            );
7768            assert!(
7769                root.join(PROJECTION).is_file(),
7770                "no projection beside the log it just seeded",
7771            );
7772            assert_eq!(app.window_title(), "saved.bwx - BlockWorx");
7773            assert!(
7774                app.recent.paths().contains(&root),
7775                "a container we just made is one to offer reopening",
7776            );
7777            drop(app);
7778
7779            let reopened = Store::open(&root, Clock::System).expect("the container reopens");
7780            assert_eq!(
7781                reopened.document().clone(),
7782                scratch,
7783                "the seeded container is not the document it was seeded from",
7784            );
7785            assert_eq!(
7786                reopened.rows().len(),
7787                commits,
7788                "the session's commits became the container's history",
7789            );
7790        }
7791
7792        /// R37's Save-as, through the lens: the container it writes holds
7793        /// the document *as shown*, the session lands in it writable at
7794        /// that rev, and the log it left behind is untouched.
7795        #[test]
7796        fn saving_while_viewing_a_rev_writes_the_document_as_shown() {
7797            let dir = TempDir::new("app-save-as-through");
7798            let source = dir.join("source.bwx");
7799            let saved = dir.join("through-2.bwx");
7800            {
7801                let mut store =
7802                    Store::create(&source, Clock::System).expect("the container is laid out");
7803                let author = Identity::new("ada");
7804                for (n, ops) in [
7805                    (1, vec![fx::block(1, 0.0), fx::top(1)]),
7806                    (2, vec![blockworx_store::fixture::block_move(1, 5)]),
7807                    (3, vec![blockworx_store::fixture::block_move(1, 9)]),
7808                ] {
7809                    store
7810                        .submit_edit(Commit::new(format!("Edit {n}"), ops), &author)
7811                        .expect("the edit lands");
7812                }
7813                store
7814                    .tag(
7815                        rev(2),
7816                        "the good one",
7817                        blockworx_store::tags::Tagging::Added,
7818                        &author,
7819                    )
7820                    .expect("the tag");
7821            }
7822            let mut app = app_opening(&source);
7823            let ctx = egui::Context::default();
7824            app.dispatch_action(&ctx, Action::ViewRev(rev(2)));
7825            let shown =
7826                blockworx_store::document_file::to_json(app.session.viewed_repo().document());
7827
7828            app.save_as_container(&ctx, &saved, crate::file::SaveScope::Through(rev(2)));
7829
7830            assert_eq!(
7831                app.session.doc.container_root(),
7832                Some(saved.as_path()),
7833                "the session did not adopt the container it just wrote",
7834            );
7835            assert_eq!(
7836                app.session.viewing(),
7837                Viewing::Head,
7838                "the lens stayed open over a document that is now the head",
7839            );
7840            assert_eq!(
7841                app.session.doc.repo().rev(),
7842                rev(2),
7843                "the new head is not the cut"
7844            );
7845            assert_eq!(
7846                blockworx_store::document_file::to_json(app.session.doc.repo().document()),
7847                shown,
7848                "what was saved is not what was on the canvas",
7849            );
7850            assert_eq!(
7851                app.session.doc.tags().of(rev(2)),
7852                ["the good one"],
7853                "the rev's name did not come with it",
7854            );
7855            assert_eq!(app.session.may_write(), Writability::Writable);
7856            assert_eq!(
7857                records(&source).len(),
7858                4,
7859                "saving a prefix rewrote the log it was cut from",
7860            );
7861            // R43: a save that worked is routine, so the status line says
7862            // it and the toast stays quiet.
7863            assert_eq!(
7864                crate::shell::status_line::showing(&ctx).as_deref(),
7865                Some("Saved through rev 2 as through-2"),
7866                "the status line did not say which rev was written",
7867            );
7868
7869            // A save that cannot happen is news too, and news is what the
7870            // toast is for — nothing about the session changed, so there is
7871            // no standing fact for a notice to carry.
7872            app.save_as_container(&ctx, &saved, crate::file::SaveScope::Whole);
7873            let said = crate::shell::toast::showing(&ctx).expect("the toast says something");
7874            assert!(
7875                said.starts_with("Could not save as through-2"),
7876                "a refused save said {said:?}",
7877            );
7878            assert!(
7879                app.notices().is_empty(),
7880                "a refused save left a notice standing on the canvas",
7881            );
7882        }
7883
7884        /// The same door, at the head: a Save-as of an open container copies
7885        /// its log rather than re-authoring its commits, so the tags — and
7886        /// the wall times, authors and record kinds beside them — survive
7887        /// the move. `Store::seeded` flattened all four, and now serves only
7888        /// the scratch session it was written for.
7889        #[test]
7890        fn saving_a_container_at_head_carries_its_tags_across() {
7891            let dir = TempDir::new("app-save-as-whole");
7892            let source = dir.join("source.bwx");
7893            let saved = dir.join("copy.bwx");
7894            {
7895                let mut store =
7896                    Store::create(&source, Clock::System).expect("the container is laid out");
7897                let author = Identity::new("ada");
7898                store
7899                    .submit_edit(
7900                        Commit::new("Built a scene".into(), vec![fx::block(1, 0.0), fx::top(1)]),
7901                        &author,
7902                    )
7903                    .expect("the scene lands");
7904                store
7905                    .tag(
7906                        rev(1),
7907                        "the start",
7908                        blockworx_store::tags::Tagging::Added,
7909                        &author,
7910                    )
7911                    .expect("the tag");
7912            }
7913            let mut app = app_opening(&source);
7914            assert_eq!(
7915                app.session.doc.tags().of(rev(1)),
7916                ["the start"],
7917                "precondition: the source names its own rev",
7918            );
7919
7920            app.save_as_container(
7921                &egui::Context::default(),
7922                &saved,
7923                crate::file::SaveScope::Whole,
7924            );
7925
7926            assert_eq!(
7927                app.session.doc.tags().of(rev(1)),
7928                ["the start"],
7929                "a whole Save-as flattened the log's tag records",
7930            );
7931            assert_eq!(
7932                std::fs::read(source.join(blockworx_store::container::MANIFEST))
7933                    .expect("the source log"),
7934                std::fs::read(saved.join(blockworx_store::container::MANIFEST)).expect("the copy"),
7935                "a whole Save-as is not the same log",
7936            );
7937        }
7938
7939        // ── The share bundle (F9, R54) ───────────────────────────────────
7940
7941        /// The round trip the feature is, through the app's own doors: a
7942        /// diagram shared to one file, opened again somewhere else, is the
7943        /// same diagram — the same log bytes, the same names on its revs, a
7944        /// head that verifies and a projection that stamps it. The zip is
7945        /// left where it was, inert.
7946        #[test]
7947        fn a_shared_diagram_opens_again_as_the_diagram_it_was() {
7948            let dir = TempDir::new("app-share-round-trip");
7949            let source = dir.join("engine.bwx");
7950            {
7951                let mut store =
7952                    Store::create(&source, Clock::System).expect("the container is laid out");
7953                let author = Identity::new("ada");
7954                store
7955                    .submit_edit(
7956                        Commit::new("Built a scene".into(), vec![fx::block(1, 0.0), fx::top(1)]),
7957                        &author,
7958                    )
7959                    .expect("the scene lands");
7960                store
7961                    .tag(
7962                        rev(1),
7963                        "the start",
7964                        blockworx_store::tags::Tagging::Added,
7965                        &author,
7966                    )
7967                    .expect("the tag");
7968                store.save_projection().expect("the projection");
7969            }
7970            let ctx = egui::Context::default();
7971            let mut app = app_opening(&source);
7972            let shared = dir.join("outbox").join("engine.bwx.zip");
7973            std::fs::create_dir_all(dir.join("outbox")).expect("the outbox");
7974
7975            app.share_bundle(&ctx, &shared);
7976
7977            assert!(shared.is_file(), "nothing was shared");
7978            assert_eq!(
7979                crate::shell::status_line::showing(&ctx).as_deref(),
7980                Some("Shared as engine.bwx.zip"),
7981                "a share that worked is routine and belongs in the status line (R43)",
7982            );
7983            assert!(
7984                crate::shell::toast::showing(&ctx).is_none(),
7985                "a share that worked raised an attention event",
7986            );
7987
7988            // Somewhere else entirely, the way a mail attachment arrives.
7989            let elsewhere = TempDir::new("app-share-inbox");
7990            let arrived = elsewhere.join("engine.bwx.zip");
7991            std::fs::copy(&shared, &arrived).expect("the bundle travels");
7992            let mut reader = app_opening(Path::new(""));
7993            reader.open_bundle(&ctx, &arrived);
7994
7995            let unpacked = elsewhere.join("engine.bwx");
7996            assert_eq!(
7997                reader.session.doc.container_root(),
7998                Some(unpacked.as_path()),
7999                "the reader is not editing the diagram that was unpacked beside the zip",
8000            );
8001            assert_eq!(
8002                std::fs::read(source.join(blockworx_store::container::MANIFEST))
8003                    .expect("the source log"),
8004                std::fs::read(unpacked.join(blockworx_store::container::MANIFEST))
8005                    .expect("the unpacked log"),
8006                "the diagram that arrived is not the bytes that were sent",
8007            );
8008            assert_eq!(
8009                reader.session.doc.tags().of(rev(1)),
8010                ["the start"],
8011                "a rev's name did not survive the trip",
8012            );
8013            assert_eq!(reader.session.may_write(), Writability::Writable);
8014            assert_eq!(
8015                reader.session.doc.projection(),
8016                Some(Freshness::Fresh),
8017                "the unpacked projection does not stamp its own head",
8018            );
8019            assert!(reader.failures.is_empty(), "the open complained");
8020            assert!(arrived.is_file(), "opening the bundle consumed it");
8021        }
8022
8023        /// A zip that is no diagram is refused by name, and the session it
8024        /// was offered to is exactly where it was.
8025        #[test]
8026        fn a_zip_that_holds_no_diagram_is_refused_and_changes_nothing() {
8027            let dir = TempDir::new("app-share-not-a-diagram");
8028            let holiday = dir.join("holiday.zip");
8029            std::fs::write(&holiday, b"not a zip at all").expect("the file");
8030            let ctx = egui::Context::default();
8031            let mut app = app_opening(Path::new(""));
8032            let before = app.session.doc.document().clone();
8033
8034            app.open_bundle(&ctx, &holiday);
8035
8036            let said = app.failures.last().expect("the refusal is on the canvas");
8037            assert!(
8038                said.starts_with("holiday.zip is not a shared diagram"),
8039                "the refusal does not name what was picked: {said}",
8040            );
8041            assert!(
8042                matches!(app.session.doc, Doc::Scratch { .. }),
8043                "a refused open moved the session",
8044            );
8045            assert_eq!(app.session.doc.document().clone(), before);
8046            assert!(
8047                !dir.join("holiday.bwx").exists(),
8048                "a refused unpack left a container behind",
8049            );
8050        }
8051
8052        /// Nothing is ever unpacked over anything: where the name beside the
8053        /// zip is taken, the open becomes a second dialog and not a write.
8054        /// The dialog itself is not driven here — what is proved is that the
8055        /// session and the directory in the way are both untouched, and that
8056        /// a destination the user then names is honoured.
8057        #[test]
8058        fn a_shared_diagram_whose_name_is_taken_asks_rather_than_overwrites() {
8059            let dir = TempDir::new("app-share-collision");
8060            let source = dir.join("engine.bwx");
8061            container_with_a_block(&source);
8062            let bundle = dir.join("engine.bwx.zip");
8063            blockworx_store::bundle::pack(&source, &bundle).expect("the bundle");
8064            let standing = std::fs::read(source.join(blockworx_store::container::MANIFEST))
8065                .expect("the log that is in the way");
8066            assert_eq!(
8067                crate::file::unpacks_to(&bundle),
8068                crate::file::Landing::Occupied(source.clone()),
8069                "precondition: the bundle would land on the diagram it came from",
8070            );
8071
8072            let ctx = egui::Context::default();
8073            let mut app = app_opening(Path::new(""));
8074            app.open_bundle(&ctx, &bundle);
8075
8076            assert!(
8077                matches!(app.session.doc, Doc::Scratch { .. }),
8078                "the collision opened something",
8079            );
8080            assert_eq!(
8081                std::fs::read(source.join(blockworx_store::container::MANIFEST)).expect("the log"),
8082                standing,
8083                "the diagram in the way was written over",
8084            );
8085            assert!(
8086                app.pending_file.is_some(),
8087                "the collision did not become a question for the user",
8088            );
8089
8090            // The answer the dialog would deliver, dispatched as it would be.
8091            let chosen = dir.join("engine (2).bwx");
8092            app.unpack_and_open(&bundle, &chosen);
8093            assert_eq!(
8094                app.session.doc.container_root(),
8095                Some(chosen.as_path()),
8096                "the destination the user named was not the one written",
8097            );
8098            assert_eq!(
8099                std::fs::read(chosen.join(blockworx_store::container::MANIFEST))
8100                    .expect("the new log"),
8101                standing,
8102                "the diagram that landed is not the one that was shared",
8103            );
8104        }
8105
8106        /// The recent list is the File menu's shortcut, and it has to
8107        /// survive the process: the app writes it on save and reads it back
8108        /// on the next launch.
8109        #[test]
8110        fn the_recent_list_survives_the_storage_round_trip() {
8111            use eframe::App as _;
8112
8113            let dir = TempDir::new("app-recent");
8114            let root = dir.join("doc.bwx");
8115            container_with_a_block(&root);
8116            let mut storage = crate::file::tests::MemoryStorage::default();
8117
8118            let mut app = app_opening(&root);
8119            app.restore_preferences(&storage);
8120            assert!(
8121                app.recent.paths().contains(&root),
8122                "opening a container did not remember it",
8123            );
8124            app.save(&mut storage);
8125            drop(app);
8126
8127            let mut next = app_opening(Path::new(""));
8128            next.restore_preferences(&storage);
8129            assert!(
8130                next.recent.paths().contains(&root),
8131                "the next session was not offered the container the last one opened",
8132            );
8133        }
8134
8135        /// A remembered container that has gone away is reported and dropped,
8136        /// so a dead entry is offered once and not twice.
8137        #[test]
8138        fn opening_a_container_that_is_gone_forgets_it() {
8139            let dir = TempDir::new("app-recent-forgets");
8140            let missing = dir.join("gone.bwx");
8141            let mut app = app_opening(Path::new(""));
8142            app.recent.remember(&missing);
8143
8144            app.open_container(&missing);
8145
8146            assert!(
8147                !app.recent.paths().contains(&missing),
8148                "a container that will not open stayed on the list",
8149            );
8150            assert!(
8151                matches!(app.session.doc, Doc::Scratch { .. }),
8152                "a failed open must leave the session where it was",
8153            );
8154        }
8155
8156        // ── The projection (D11) ─────────────────────────────────────────
8157
8158        use blockworx_store::container::PROJECTION;
8159        use blockworx_store::projection::{Freshness, stamp_in};
8160
8161        /// Invoke a command through the registry, exactly as a chord or the
8162        /// palette would — so the availability gate is part of what the
8163        /// test drives, not something it steps around.
8164        fn invoke(app: &mut App, ctx: &egui::Context, id: CommandId) -> bool {
8165            let Some(action) = app
8166                .session
8167                .available_commands(crate::edit::naming::InterfaceLock::Unlocked)
8168                .take(id)
8169            else {
8170                return false;
8171            };
8172            app.dispatch_action(ctx, action);
8173            true
8174        }
8175
8176        #[test]
8177        fn saving_writes_the_projection_and_clears_the_staleness_marker() {
8178            let ctx = egui::Context::default();
8179            let dir = TempDir::new("app-save-projection");
8180            let root = dir.join("doc.bwx");
8181            container_with_a_block(&root);
8182
8183            let mut app = app_opening(&root);
8184            assert_eq!(
8185                app.session.doc.projection(),
8186                Some(Freshness::Stale),
8187                "precondition: the container has a log and nothing projected from it",
8188            );
8189            assert!(
8190                !app.window_title().contains('\u{2022}'),
8191                "staleness never reaches the title — there are no unsaved \
8192                 changes to warn about: {}",
8193                app.window_title(),
8194            );
8195
8196            assert!(invoke(&mut app, &ctx, CommandId::Save), "Save is offered");
8197
8198            assert_eq!(app.session.doc.projection(), Some(Freshness::Fresh));
8199            let text = std::fs::read_to_string(root.join(PROJECTION)).expect("the projection");
8200            assert_eq!(
8201                &blockworx_store::document_file::parse(&text, PROJECTION)
8202                    .expect("it reads back as a document"),
8203                app.session.doc.repo().document(),
8204                "the file is not the projection of the log it sits beside",
8205            );
8206
8207            // And an edit after it puts the marker back, since the log has
8208            // moved on from what the file shows.
8209            app.dispatch_action(
8210                &ctx,
8211                Action::Delete(crate::tools::tool::Deletable::Shape(
8212                    crate::shape::ShapeId::Rect(block_id(1)),
8213                )),
8214            );
8215            assert_eq!(app.session.doc.projection(), Some(Freshness::Stale));
8216        }
8217
8218        /// The projection follows the log by itself: once the head sits
8219        /// still for the settle window, a stale `document.json` is
8220        /// rewritten with no one asking — and a moving head is waited out,
8221        /// not chased.
8222        #[test]
8223        fn a_stale_projection_refreshes_itself_once_the_head_settles() {
8224            let ctx = egui::Context::default();
8225            let dir = TempDir::new("app-projection-settle");
8226            let root = dir.join("doc.bwx");
8227            container_with_a_block(&root);
8228
8229            let mut app = app_opening(&root);
8230            assert_eq!(
8231                app.session.doc.projection(),
8232                Some(Freshness::Stale),
8233                "precondition: a log with nothing projected beside it",
8234            );
8235
8236            let opened = std::time::Instant::now();
8237            app.refresh_projection_at(&ctx, opened);
8238            assert_eq!(
8239                app.session.doc.projection(),
8240                Some(Freshness::Stale),
8241                "the first sighting arms the timer; it must not write yet",
8242            );
8243            app.refresh_projection_at(&ctx, opened + App::PROJECTION_SETTLE / 2);
8244            assert_eq!(
8245                app.session.doc.projection(),
8246                Some(Freshness::Stale),
8247                "half a settle window is not settled",
8248            );
8249            app.refresh_projection_at(&ctx, opened + App::PROJECTION_SETTLE);
8250            assert_eq!(
8251                app.session.doc.projection(),
8252                Some(Freshness::Fresh),
8253                "a settled head gets its projection written",
8254            );
8255        }
8256
8257        /// A projection nobody's fold wrote is flagged rather than trusted,
8258        /// and the next save takes the file back (D11).
8259        #[test]
8260        fn a_projection_this_log_never_wrote_is_flagged_and_then_overwritten() {
8261            let ctx = egui::Context::default();
8262            let dir = TempDir::new("app-projection-hand-edited");
8263            let root = dir.join("doc.bwx");
8264            container_with_a_block(&root);
8265            std::fs::write(root.join(PROJECTION), "{\"top\": \"b0\", \"blocks\": []}")
8266                .expect("a hand-written projection");
8267
8268            let mut app = app_opening(&root);
8269            assert_eq!(app.session.doc.projection(), Some(Freshness::Unrecognized));
8270
8271            assert!(invoke(&mut app, &ctx, CommandId::Save), "Save is offered");
8272            assert_eq!(app.session.doc.projection(), Some(Freshness::Fresh));
8273            let text = std::fs::read_to_string(root.join(PROJECTION)).expect("the projection");
8274            assert!(
8275                matches!(
8276                    stamp_in(&text),
8277                    blockworx_store::projection::Found::Stamped(_)
8278                ),
8279                "the overwrite left a file with no stamp on it",
8280            );
8281        }
8282
8283        /// Save refreshes a file beside a log; a session with no container,
8284        /// or one it may not write, has neither.
8285        #[test]
8286        fn save_is_withheld_without_a_writable_container() {
8287            let dir = TempDir::new("app-save-withheld");
8288            let root = dir.join("doc.bwx");
8289            container_with_a_block(&root);
8290
8291            let scratch = super::app_on(vec![fx::block(1, 0.0), fx::top(1)]);
8292            assert_eq!(
8293                scratch.session.doc.saving(),
8294                blockworx_store::doc::Saving::Withheld
8295            );
8296            let mut scratch = scratch;
8297            assert!(
8298                !scratch
8299                    .session
8300                    .available_commands(crate::edit::naming::InterfaceLock::Unlocked)
8301                    .contains(CommandId::Save),
8302                "a scratch session has no projection to refresh",
8303            );
8304
8305            let _held = Store::open(&root, Clock::System).expect("the first session holds it");
8306            let mut locked = app_opening(&root);
8307            assert!(
8308                locked.session.doc.read_only_reason().is_some(),
8309                "precondition: the lock is held",
8310            );
8311            assert!(
8312                !locked
8313                    .session
8314                    .available_commands(crate::edit::naming::InterfaceLock::Unlocked)
8315                    .contains(CommandId::Save),
8316                "a container this session may not write is not one to save into",
8317            );
8318        }
8319
8320        /// The document format's own round trip, through the two paths a
8321        /// user takes: what Export writes is what Import reads. Since D19
8322        /// the whole document arrives as one block, so both of the source's
8323        /// blocks have to survive — the top as the new block, its child
8324        /// beneath it.
8325        #[test]
8326        fn a_json_export_imports_back_through_the_real_dispatch() {
8327            let mut source = super::app_on(vec![
8328                fx::block(1, 0.0),
8329                fx::block_in(
8330                    2,
8331                    crate::path::Scope::Block(block_id(1)),
8332                    Rect::from_min_max(pos2(4.0, 4.0), pos2(24.0, 24.0)),
8333                ),
8334                fx::top(1),
8335            ]);
8336            let exported = source
8337                .export_content(crate::export::ExportFormat::Json, None)
8338                .text()
8339                .expect("a JSON export is text")
8340                .to_owned();
8341            assert!(
8342                exported.contains("\"blocks\""),
8343                "precondition: the export is a JSON document:\n{exported}",
8344            );
8345
8346            let mut target = super::app_on(vec![fx::block(9, 0.0), fx::top(9)]);
8347            let before = live_blocks(&target);
8348            target
8349                .session
8350                .handle_imported("exported.json", exported.into_bytes());
8351
8352            assert_eq!(
8353                live_blocks(&target),
8354                before + 2,
8355                "the exported document did not arrive whole, as one block over its child",
8356            );
8357        }
8358
8359        fn live_blocks(app: &App) -> usize {
8360            app.session.doc.document().blocks().count()
8361        }
8362    }
8363
8364    #[test]
8365    fn tool_cursor_shows_only_while_canvas_hovered() {
8366        assert_eq!(
8367            effective_cursor(Some(Cursor::Crosshair), PointerOver::Canvas),
8368            Some(Cursor::Crosshair)
8369        );
8370        assert_eq!(
8371            effective_cursor(Some(Cursor::Crosshair), PointerOver::Elsewhere),
8372            None
8373        );
8374        assert_eq!(effective_cursor(None, PointerOver::Canvas), None);
8375    }
8376}
8377
8378#[cfg(all(test, feature = "kittest"))]
8379mod kittest_visual {
8380    use super::{App, AppConfig};
8381    use crate::canvas::convert::IntoEgui as _;
8382    use crate::shell::picture::{Width, at_every_width, dress, dressed};
8383    use crate::shell::workspace::PanelView;
8384    use crate::tools::tool::Action;
8385    use blockworx_geom::{Vec2, vec2};
8386    use egui_kittest::Harness;
8387
8388    /// The frame at rest: the docked top bar across the top — document menu,
8389    /// live dot, breadcrumb, and the right-hand actions with undo and redo
8390    /// drawn dead (R41) — the floating tool rail down the left with Select
8391    /// armed, the status line reading zoom and pointer at the bottom, and the
8392    /// canvas running edge to edge under all of it. The bar's centre is
8393    /// empty, no past rev being on the canvas.
8394    #[test]
8395    fn shell_frame() {
8396        at_every_width("shell_frame", |width| picture(width, Shown::Idle));
8397    }
8398
8399    /// The navigator overlaying from the right: flush, full height under the
8400    /// bar, translucent, with no scrim and no close control. The 1024 picture
8401    /// is the narrow variant — the only thing a smaller window changes about
8402    /// it (§8).
8403    #[test]
8404    fn shell_navigator() {
8405        at_every_width("shell_navigator", |width| picture(width, Shown::History));
8406    }
8407
8408    /// The same panel with another of its segments open: the block tree, its
8409    /// filter box over it, under the segments that say which of the three the
8410    /// navigator is showing.
8411    #[test]
8412    fn shell_navigator_hierarchy() {
8413        at_every_width("shell_navigator_hierarchy", |width| {
8414            picture(width, Shown::Hierarchy)
8415        });
8416    }
8417
8418    /// §3's selection overlay: the bar of commands for one selected block,
8419    /// centred above it with 14px of air, its first five verbs in the row and
8420    /// the rest behind the ellipsis. Taken with the navigator open, so the
8421    /// picture also shows the bar clamping into the room the chrome left
8422    /// (§3.3).
8423    #[test]
8424    fn shell_selection_overlay() {
8425        at_every_width("shell_selection_overlay", |width| {
8426            picture(width, Shown::Selection)
8427        });
8428    }
8429
8430    /// The toast (R38, R43): what needs attention, at the
8431    /// bottom of the frame. It is the one piece of chrome that leaves on its
8432    /// own, so the picture is taken while it holds.
8433    #[test]
8434    fn shell_toast() {
8435        at_every_width("shell_toast", |width| picture(width, Shown::Toast));
8436    }
8437
8438    /// The read-only lens, and §6.2's signals in one picture: the bar tinted
8439    /// amber with the rev, the stepper and Return in its centre, the dot
8440    /// amber beside the breadcrumb, the tool rail dimmed and inert, and the
8441    /// canvas drained of its colour.
8442    #[test]
8443    fn shell_frame_viewing() {
8444        at_every_width("shell_frame_viewing", |width| picture(width, Shown::Lens));
8445    }
8446
8447    /// Which of the frame's states a picture is taken in.
8448    #[derive(Clone, Copy)]
8449    enum Shown {
8450        Idle,
8451        History,
8452        Hierarchy,
8453        Lens,
8454        Selection,
8455        Toast,
8456    }
8457
8458    fn picture(width: Width, shown: Shown) -> Harness<'static> {
8459        picture_at(vec2(width.points(), 800.0), shown)
8460    }
8461
8462    #[expect(
8463        clippy::expect_used,
8464        reason = "a fixture that will not fold has no picture to take"
8465    )]
8466    fn picture_at(size: Vec2, shown: Shown) -> Harness<'static> {
8467        let mut app = App::new(AppConfig::default());
8468        let repo = blockworx_doc::repo::Repo::folding(&blockworx_store::fixture::edits(3))
8469            .expect("the edits fold");
8470        let _ = app.session.adopt(blockworx_store::doc::Doc::scratch(repo));
8471        // Every real path that adopts a document rebuilds the stack over
8472        // it. Without this the stack still stands on the empty document
8473        // the session opened with, and under the lens the undo button
8474        // reads that as a *document* step and draws dead.
8475        app.session.undo_stack =
8476            crate::history::UndoStack::reconstructed(app.session.doc.trail(), &app.session.state());
8477        match shown {
8478            Shown::Idle | Shown::Lens | Shown::Toast => {}
8479            Shown::History | Shown::Selection => app.workspace.show(PanelView::History),
8480            Shown::Hierarchy => app.workspace.show(PanelView::Hierarchy),
8481        }
8482        if let Shown::Selection = shown {
8483            let shape = crate::shape::ShapeId::Rect(blockworx_doc::fixtures::block_id(1));
8484            app.session.tool = crate::tools::resize_block::ResizeBlock::Selected { shape }.into();
8485        }
8486        if let Shown::Lens = shown {
8487            app.dispatch_action(
8488                &egui::Context::default(),
8489                Action::ViewRev(blockworx_doc::fixtures::rev(2)),
8490            );
8491        }
8492        let mut said = false;
8493        Harness::builder()
8494            .with_size(size.egui())
8495            .build_ui(move |ui| {
8496                dress(ui.ctx());
8497                if dressed(ui.ctx()) {
8498                    // Said once: the toast carries its own clock, and re-raising
8499                    // it every pass would hold it at the first frame of its rise.
8500                    if let Shown::Toast = shown
8501                        && !std::mem::replace(&mut said, true)
8502                    {
8503                        crate::shell::toast::say(ui.ctx(), "Could not export motor-controller.pdf");
8504                    }
8505                    app.shell_frame(ui);
8506                    // Re-framed each pass, so the picture shows §2.1 settled:
8507                    // an `Area` reports a stale position on the pass it is
8508                    // first sized in, and a single fit would be taken against
8509                    // that. The framing is idempotent, so this comes to rest.
8510                    app.dispatch_action(ui.ctx(), Action::ResetView);
8511                }
8512            })
8513    }
8514}