1use blockworx_geom::{Pos2, Rect, vec2};
2use blockworx_paint::{Cursor, FontChoice, Scheme};
3
4use crate::canvas::convert::{IntoEgui as _, IntoGeom};
5#[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#[cfg(not(target_arch = "wasm32"))]
38use crate::tools::{SelectTool, tool::Tool};
39use blockworx_doc::id::{BlockId, PinId};
40use blockworx_doc::repo::Repo;
41
42fn 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#[cfg(not(target_arch = "wasm32"))]
50#[derive(Default)]
51pub enum Opening {
52 Path(PathBuf),
55 Born,
58 #[default]
61 Detached,
62}
63
64#[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 #[cfg(not(target_arch = "wasm32"))]
76 pub documents: blockworx_store::naming::Documents,
77 pub theme_editor: bool,
80 pub font_editor: bool,
83}
84
85const WORKSPACES: &str = "workspaces";
88
89const SELECTION_SEPARATOR: &str = "/";
92
93pub struct App {
94 session: Session,
99 #[cfg(not(target_arch = "wasm32"))]
103 opened: Option<String>,
104 #[cfg(not(target_arch = "wasm32"))]
109 opened_from: Option<blockworx_store::projection::Provenance>,
110 failures: Vec<String>,
114 #[cfg(not(target_arch = "wasm32"))]
117 recent: crate::file::RecentFiles,
118 #[cfg(not(target_arch = "wasm32"))]
120 documents: blockworx_store::naming::Documents,
121 #[cfg(not(target_arch = "wasm32"))]
127 unclaimed: Vec<PathBuf>,
128 #[cfg(not(target_arch = "wasm32"))]
131 rename_draft: String,
132 #[cfg(not(target_arch = "wasm32"))]
135 pending_file: Option<crate::file::PickReceiver>,
136 pub preferences: Preferences,
139 applied_appearance: Option<(Scheme, bool, FontChoice)>,
142 #[cfg(not(target_arch = "wasm32"))]
145 applied_title: String,
146 #[cfg(not(target_arch = "wasm32"))]
148 head_moved: Option<(blockworx_doc::rev::Rev, std::time::Instant)>,
149 canvas: View,
150 theme_editor: bool,
153 font_editor: bool,
156 images_loaded: bool,
159 popup: Option<Popup>,
164 overlay_top_right: Option<Pos2>,
168 selection_screen_bounds: Option<Rect>,
174 palette: Option<Palette>,
176 pending_import: Option<ImportReceiver>,
179 pending_image: Option<(ImageTarget, ImageReceiver)>,
183 workspace: crate::shell::workspace::Workspace,
186 safe: crate::shell::SafeArea,
190 workspaces: std::collections::BTreeMap<String, crate::shell::workspace::Workspace>,
194 history_search: String,
198}
199
200enum Popup {
203 Role(RoleTarget),
205 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
219type ImportReceiver = std::sync::mpsc::Receiver<Option<(String, Vec<u8>)>>;
222
223type ImageReceiver = std::sync::mpsc::Receiver<Option<blockworx_doc::block_model::Asset>>;
226
227fn 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
235fn 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#[derive(Clone, Copy, PartialEq, Eq)]
248enum PointerOver {
249 Canvas,
250 Elsewhere,
251}
252
253fn 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
276fn 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
286fn 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
305fn 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"))]
316fn 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#[cfg(not(target_arch = "wasm32"))]
330enum Loaded {
331 Document {
335 repo: Box<Repo>,
336 name: String,
337 from: Option<blockworx_store::projection::Provenance>,
338 },
339 Nothing,
341 Failed(String),
343}
344
345#[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#[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 tracing::error!("Failed to open {}:\n{e:?}", path.display());
390 Loaded::Failed(format!("Failed to open {}: {e}", path.display()))
391 }
392 }
393}
394
395#[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 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#[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#[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#[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#[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 #[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 #[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 #[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 #[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 #[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 self.failures.clear();
623 self.session.fit_view();
624 }
625
626 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 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 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 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 let selection_repo = selection.and_then(|shapes| self.selection_repo(&shapes));
695 match format {
696 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 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 fn text_layout(&self) -> crate::canvas::EpaintLayout {
750 crate::canvas::EpaintLayout::new(self.preferences.font)
751 }
752
753 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 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 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 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 pub fn apply_preferences(&mut self, ctx: &egui::Context) {
820 ctx.options_mut(|o| o.zoom_with_keyboard = false);
825 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 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 self.session.identity = self.preferences.identity();
866 #[cfg(not(target_arch = "wasm32"))]
867 {
868 self.recent = crate::file::RecentFiles::restore(storage);
869 if let Some(root) = self.attached_root()
874 && !self.unclaimed.contains(&root)
875 {
876 self.recent.remember(&root);
877 }
878 }
879 }
880
881 #[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 #[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 #[cfg_attr(target_arch = "wasm32", allow(dead_code, clippy::unused_self))]
904 fn save_theme(&self) {
905 #[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 #[cfg_attr(target_arch = "wasm32", allow(dead_code, clippy::unused_self))]
930 fn save_font_sizes(&self) {
931 #[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 fn show_popups(&mut self, ctx: &egui::Context) -> (Option<OpenPicker>, Option<Action>) {
958 let was_open = self.popup.as_ref().map(Popup::picker);
959 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 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 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 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 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 fn show_canvas(&mut self, ui: &mut egui::Ui) -> Option<Action> {
1068 let chrome = CanvasChrome {
1073 background: self.session.chrome_color(Role::CanvasBackground),
1074 grid: self.session.chrome_color(Role::GridLine),
1075 };
1076 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 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 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 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 if overlay_corner.is_none() {
1168 self.popup = None;
1169 }
1170 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 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 self.show_document_notices(ui, viewport, Rect::NOTHING);
1212 action
1213 }
1214
1215 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 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 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 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 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 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 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 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 #[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 fn date_of(&self, rev: blockworx_doc::rev::Rev) -> Option<String> {
1463 self.written_at(rev).map(blockworx_store::history::date)
1464 }
1465
1466 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 #[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 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(), ¬ices)
1510 {
1511 self.failures.remove(failure);
1512 }
1513 }
1514
1515 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 #[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 #[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 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 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 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 fn dispatch_action(&mut self, ctx: &egui::Context, action: Action) {
1600 let was = self.session.viewing();
1605 let unhandled = self.session.dispatch(action);
1606 if self.session.viewing() != was {
1607 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 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 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 fn sync_camera(&mut self, ctx: &egui::Context) {
1670 let sighting = self.sighting(ctx);
1671 self.session.sees(sighting);
1672 }
1673
1674 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 fn open_popup(&mut self, ctx: &egui::Context, popup: Popup) {
1695 self.popup = Some(popup);
1696 ctx.request_repaint();
1697 }
1698
1699 #[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 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 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 #[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 self.take_document(Doc::default());
1741 self.report_failure(failure.notice());
1742 }
1743 }
1744 }
1745
1746 #[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 self.settle_workspace(&crate::file::document_name(&root));
1769 self.claim(&to);
1772 }
1773
1774 #[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 #[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 #[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 #[cfg(not(target_arch = "wasm32"))]
1831 fn open_container(&mut self, root: &Path) {
1832 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 #[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 #[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 Err(why) => self.report_failure(format!(
1888 "{} is not a shared diagram: {why}",
1889 crate::file::container_name(bundle),
1890 )),
1891 }
1892 }
1893
1894 #[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 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 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 #[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 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 self.session.undo_stack = crate::history::UndoStack::reconstructed(
1974 self.session.doc.trail(),
1975 &self.session.state(),
1976 );
1977 self.save_projection();
1980 self.session.view_head();
1984 crate::shell::status_line::say(ctx, saved_as(root, scope));
1988 }
1989 Err(refusal) => {
1990 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 #[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 #[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 #[cfg(not(target_arch = "wasm32"))]
2060 const PROJECTION_SETTLE: core::time::Duration = core::time::Duration::from_secs(2);
2061
2062 #[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 Ok(None) | Err(std::sync::mpsc::TryRecvError::Disconnected) => {
2085 self.pending_file = None;
2086 }
2087 Err(std::sync::mpsc::TryRecvError::Empty) => {
2090 ctx.request_repaint_after(std::time::Duration::from_millis(100));
2091 }
2092 }
2093 }
2094
2095 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 Ok(None) | Err(std::sync::mpsc::TryRecvError::Disconnected) => {
2107 self.pending_import = None;
2108 }
2109 Err(std::sync::mpsc::TryRecvError::Empty) => {
2112 ctx.request_repaint_after(std::time::Duration::from_millis(100));
2113 }
2114 }
2115 }
2116
2117 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 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
2132 self.pending_image = None;
2133 }
2134 Err(std::sync::mpsc::TryRecvError::Empty) => {
2137 ctx.request_repaint_after(std::time::Duration::from_millis(100));
2138 }
2139 }
2140 }
2141
2142 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 pub(crate) fn shell_frame(&mut self, ui: &mut egui::Ui) {
2174 let _frame_span = tracing::info_span!("frame").entered();
2175 let ctx_owned = ui.ctx().clone();
2180 let ctx = &ctx_owned;
2181 self.apply_preferences(ctx);
2184 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 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 let object_paste = self.intercept_object_paste(ctx);
2205 let view_before = self.session.state();
2208 let stood = self.session.doc.repo().rev();
2212
2213 let current_lock: InterfaceLock = self.session.drawing().current_locked().into();
2216 let mut commands = self.session.available_commands(current_lock);
2217 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 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 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 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 self.dismiss_navigator(ctx);
2282 }
2283 band(drawn.action);
2284 }
2285 self.show_status_line(&mut chrome);
2286 crate::shell::toast::toast(ctx, &self.session.theme);
2290 self.safe = chrome.safe();
2294 self.canvas.set_safe_region(self.safe.region().geom());
2295 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 self.sync_camera(ctx);
2304
2305 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 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 #[cfg(not(target_arch = "wasm32"))]
2334 self.claim_if_written();
2335 }
2336
2337 fn workspace_key(&self) -> String {
2339 self.document_name()
2340 }
2341
2342 #[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 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 #[cfg(not(target_arch = "wasm32"))]
2380 fn on_exit(&mut self) {
2381 if self.theme_editor {
2384 self.save_theme();
2385 }
2386 if self.font_editor {
2387 self.save_font_sizes();
2388 }
2389 if self.session.doc.saving() == blockworx_store::doc::Saving::Offered {
2393 self.save_projection();
2394 }
2395 self.session.doc = Doc::default();
2399 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 #[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 #[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 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 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 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 #[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 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 #[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 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 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 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 #[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 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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 fn screen_of() -> Rect {
3096 Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0))
3097 }
3098
3099 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 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 #[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 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 #[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 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 #[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 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 #[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 #[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 #[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 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 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 #[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 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 #[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 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 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 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 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 #[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 #[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 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 #[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 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 #[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 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 #[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 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 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 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 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 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 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 #[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 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 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 #[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 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 #[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 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 #[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 #[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 #[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 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 #[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 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 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 #[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 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 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 #[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 #[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 #[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 #[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 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 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 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 #[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 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 #[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 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 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 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 #[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 #[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 #[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 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 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 #[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 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 #[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 fn screen() -> Rect {
6397 Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0))
6398 }
6399
6400 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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 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 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 #[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 #[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 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 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 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 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 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 fn blocks_in_scope(app: &App) -> Vec<BlockId> {
6841 children_of(app, app.session.path.scope().wire_id())
6842 }
6843
6844 #[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 let head = provenance_of(©_rev(&mut app, &ctx, rev(3)));
6886 assert_eq!(head.rev, rev(3));
6887 assert!(head.tags.is_empty());
6888 }
6889
6890 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 use blockworx_store::container::PROJECTION;
8159 use blockworx_store::projection::{Freshness, stamp_in};
8160
8161 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 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 #[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 #[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 #[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 #[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 #[test]
8395 fn shell_frame() {
8396 at_every_width("shell_frame", |width| picture(width, Shown::Idle));
8397 }
8398
8399 #[test]
8404 fn shell_navigator() {
8405 at_every_width("shell_navigator", |width| picture(width, Shown::History));
8406 }
8407
8408 #[test]
8412 fn shell_navigator_hierarchy() {
8413 at_every_width("shell_navigator_hierarchy", |width| {
8414 picture(width, Shown::Hierarchy)
8415 });
8416 }
8417
8418 #[test]
8424 fn shell_selection_overlay() {
8425 at_every_width("shell_selection_overlay", |width| {
8426 picture(width, Shown::Selection)
8427 });
8428 }
8429
8430 #[test]
8434 fn shell_toast() {
8435 at_every_width("shell_toast", |width| picture(width, Shown::Toast));
8436 }
8437
8438 #[test]
8443 fn shell_frame_viewing() {
8444 at_every_width("shell_frame_viewing", |width| picture(width, Shown::Lens));
8445 }
8446
8447 #[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 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 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 app.dispatch_action(ui.ctx(), Action::ResetView);
8511 }
8512 })
8513 }
8514}