diff --git a/Cargo.lock b/Cargo.lock index 64b184d..0261a41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,8 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ + "enumn", + "serde", "uuid", ] @@ -146,6 +148,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -449,6 +452,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "block2" @@ -833,6 +839,7 @@ checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" dependencies = [ "bytemuck", "emath", + "serde", ] [[package]] @@ -861,6 +868,8 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "ron", + "serde", "static_assertions", "wasm-bindgen", "web-sys", @@ -885,6 +894,8 @@ dependencies = [ "log", "nohash-hasher", "profiling", + "ron", + "serde", "smallvec", "unicode-segmentation", "web-sys", @@ -926,6 +937,7 @@ dependencies = [ "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", + "serde", "smithay-clipboard", "web-time", "webbrowser", @@ -996,6 +1008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -1045,6 +1058,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "epaint" version = "0.36.2" @@ -1063,6 +1087,7 @@ dependencies = [ "parking_lot", "profiling", "self_cell", + "serde", "skrifa", "smallvec", "unicode-general-category", @@ -1184,6 +1209,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -2908,6 +2934,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.1", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3145,6 +3185,9 @@ name = "smallvec" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +dependencies = [ + "serde", +] [[package]] name = "smithay-client-toolkit" @@ -3479,6 +3522,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "uds_windows" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index f36d8eb..cb163af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -eframe = "0.36.2" +eframe = { version = "0.36.2", features = ["persistence"] } egui_extras = { version = "0.36.2", features = ["image", "file"] } image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] } open = "5.4.3" diff --git a/src/app.rs b/src/app.rs index d497be3..e919551 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,5 @@ -//! The application shell: menus, toolbar, inspector, status bar, dialogs and -//! everything that touches the file system. +//! The application shell: menus, status bar, dialogs and everything that +//! touches the file system. use std::path::{Path, PathBuf}; @@ -9,10 +9,11 @@ use eframe::egui::{ use crate::document::Document; use crate::geometry::{bounds_of, node_rect}; -use crate::inspector; use crate::model::{Canvas, Node, NodeKind}; +use crate::nodes::relative_to; +use crate::settings::Settings; use crate::theme::Palette; -use crate::view::{CanvasView, Clipboard, EditTarget, InsertRequest}; +use crate::view::{CanvasView, Clipboard, InsertRequest, PickRequest}; /// How long a status message stays on screen. const STATUS_SECONDS: f64 = 5.0; @@ -36,8 +37,7 @@ pub struct App { doc: Document, view: CanvasView, clipboard: Clipboard, - dark_mode: bool, - show_inspector: bool, + settings: Settings, status: Option<(String, f64)>, error: Option, pending: Option, @@ -51,12 +51,14 @@ pub struct App { impl App { pub fn new(cc: &eframe::CreationContext<'_>, open: Option) -> Self { egui_extras::install_image_loaders(&cc.egui_ctx); + let settings = Settings::load(cc.storage); + let mut view = CanvasView::new(); + view.snap_to_grid = settings.snap_to_grid; let mut app = Self { doc: Document::new(), - view: CanvasView::new(), + view, clipboard: Clipboard::default(), - dark_mode: true, - show_inspector: true, + settings, status: None, error: None, pending: None, @@ -74,7 +76,7 @@ impl App { } fn palette(&self) -> Palette { - if self.dark_mode { + if self.settings.dark_mode { Palette::dark() } else { Palette::light() @@ -134,13 +136,37 @@ impl App { Ok(doc) => { self.doc = doc; self.reset_view(); + self.settings.remember(path); + } + Err(err) => { + // A canvas that will not open is no use on the recent list, + // and the usual reason is that it is no longer there. + self.settings.forget(path); + self.error = Some(err); } - Err(err) => self.error = Some(err), } } + /// Opens a canvas picked from the recent list. + fn open_recent(&mut self, path: &Path, ctx: &Context) { + if !path.exists() { + self.settings.forget(path); + self.error = Some(format!("{} is no longer there.", path.display())); + return; + } + if self.guard(Pending::OpenPath(path.to_path_buf())) { + self.open_path(path); + let name = self.doc.display_name(); + self.set_status(format!("Opened {name}"), ctx); + } + } + + /// A fresh view on the canvas now open, keeping the settings that belong + /// to the editor rather than to any one canvas. fn reset_view(&mut self) { + let snap = self.view.snap_to_grid; self.view = CanvasView::new(); + self.view.snap_to_grid = snap; self.view.zoom_to_fit(&self.doc.canvas); } @@ -208,6 +234,7 @@ impl App { match self.doc.save_as(&path) { Ok(()) => { let name = self.doc.display_name(); + self.settings.remember(&path); self.set_status(format!("Saved {name}"), ctx); true } @@ -300,7 +327,7 @@ impl App { let Some(path) = dialog.pick_file() else { return; }; - let file = inspector::relative_to(self.doc.base_dir().as_deref(), &path); + let file = relative_to(self.doc.base_dir().as_deref(), &path); self.view.insert_node( &mut self.doc, NodeKind::File { @@ -319,7 +346,7 @@ impl App { } fn insert_link_node(&mut self, at: Pos2, ctx: &Context) { - self.view.insert_node( + let id = self.view.insert_node( &mut self.doc, NodeKind::Link { url: "https://".to_owned(), @@ -327,11 +354,29 @@ impl App { }, at, ); - self.show_inspector = true; - self.set_status( - "Added a link node — set its URL and display text in the inspector", - ctx, - ); + // Straight into the editor, because an empty link node shows nothing + // and there is no typing into the card itself. + self.view.edit_node(&id); + self.set_status("Added a link node", ctx); + } + + /// Puts the file the user chose on the node that asked for one. + fn pick_file(&mut self, request: &PickRequest, ctx: &Context) { + let mut dialog = rfd::FileDialog::new(); + if let PickRequest::GroupBackground(_) = request { + dialog = dialog + .add_filter("Images", &["png", "jpg", "jpeg", "gif", "bmp", "webp"]) + .add_filter("All files", &["*"]); + } + if let Some(dir) = self.doc.base_dir() { + dialog = dialog.set_directory(dir); + } + let Some(path) = dialog.pick_file() else { + return; + }; + let chosen = relative_to(self.doc.base_dir().as_deref(), &path); + self.view.apply_pick(&mut self.doc, request, &chosen); + self.set_status(format!("Set {chosen}"), ctx); } /// Adds the node a piece of loose text should become: a link node when the @@ -507,9 +552,6 @@ impl App { if consume(ctx, CTRL, K::G) { self.group_selection(ctx); } - if consume(ctx, CTRL, K::I) { - self.show_inspector = !self.show_inspector; - } if consume(ctx, CTRL, K::Num0) { self.view.zoom_to_fit(&self.doc.canvas); } @@ -552,14 +594,12 @@ impl App { self.view.stop_editing(); self.view.selection.clear(); } - if ctx.input_mut(|i| i.key_pressed(Key::F2)) - && let Some(id) = self.view.selection.only_node().map(str::to_owned) - && let EditTarget::Inspector(reason) = self.view.begin_edit(&self.doc, &id) - { - // Nothing to type into, so the fields that can be changed are put - // where they are instead. - self.show_inspector = true; - self.set_status(reason, ctx); + if ctx.input_mut(|i| i.key_pressed(Key::F2)) { + if let Some(id) = self.view.selection.only_node().map(str::to_owned) { + self.view.edit_node(&id); + } else if let Some(id) = self.view.selection.only_edge().map(str::to_owned) { + self.view.edit_edge(&id); + } } if ctx.input_mut(|i| i.key_pressed(Key::F)) && !self.view.selection.is_empty() { self.view.zoom_to_selection(&self.doc.canvas); @@ -626,7 +666,7 @@ impl App { } // A file below the canvas is stored relative to it, so the pair // survives being moved or shared; anything else keeps its full path. - let file = inspector::relative_to(self.doc.base_dir().as_deref(), &path); + let file = relative_to(self.doc.base_dir().as_deref(), &path); // Step each further file down and right instead of stacking them all // on the same spot. let at = centre + egui::vec2(24.0, 24.0) * added.len() as f32; @@ -711,6 +751,7 @@ impl App { if item(ui, "Open…", "Ctrl+O") { self.open_dialog(ctx); } + ui.menu_button("Open recent", |ui| self.recent_menu(ui, ctx)); ui.separator(); if item(ui, "Save", "Ctrl+S") { self.save(ctx); @@ -791,6 +832,23 @@ impl App { if item(ui, "Delete selection", "Del") { self.delete_selection(ctx); } + ui.separator(); + // An edge left pointing at a node that has gone is legal in + // the file and draws nothing, so it takes saying so to find. + let dangling = self.dangling_edges(); + if ui + .add_enabled(dangling > 0, Button::new("Remove dangling edges")) + .on_hover_text(format!( + "{dangling} edge(s) point at nodes that do not exist" + )) + .on_disabled_hover_text("Every edge joins two nodes that are there") + .clicked() + { + self.doc.change("Remove dangling edges"); + let removed = self.doc.canvas.drop_dangling_edges(); + self.set_status(format!("Removed {removed} dangling edge(s)"), ctx); + ui.close(); + } }); ui.menu_button("Insert", |ui| { @@ -835,8 +893,7 @@ impl App { } ui.separator(); ui.checkbox(&mut self.view.snap_to_grid, "Snap to grid"); - ui.checkbox(&mut self.show_inspector, "Inspector"); - ui.checkbox(&mut self.dark_mode, "Dark mode"); + ui.checkbox(&mut self.settings.dark_mode, "Dark mode"); }); ui.menu_button("Help", |ui| { @@ -855,95 +912,90 @@ impl App { }); }); } + /// The canvases opened before, newest first. One that has gone from disk + /// is dropped from the list when it is picked, which is when it matters. + fn recent_menu(&mut self, ui: &mut Ui, ctx: &Context) { + if self.settings.recent.is_empty() { + ui.label(RichText::new("Nothing opened yet").weak()); + return; + } + for path in self.settings.recent.clone() { + if ui + .button(name_of(&path)) + .on_hover_text(path.display().to_string()) + .clicked() + { + self.open_recent(&path, ctx); + ui.close(); + } + } + ui.separator(); + if ui.button("Clear the list").clicked() { + self.settings.recent.clear(); + ui.close(); + } + } - fn toolbar(&mut self, ui: &mut Ui, ctx: &Context) { - ui.horizontal(|ui| { - if ui - .button("New") - .on_hover_text("New canvas (Ctrl+N)") - .clicked() - { - self.new_document(ctx); - } - if ui - .button("Open") - .on_hover_text("Open a canvas (Ctrl+O)") - .clicked() - { - self.open_dialog(ctx); - } - if ui.button("Save").on_hover_text("Save (Ctrl+S)").clicked() { - self.save(ctx); - } - ui.separator(); - if ui - .add_enabled(self.doc.can_undo(), Button::new("Undo")) - .on_hover_text("Undo (Ctrl+Z)") - .clicked() - { - self.undo(ctx); - } - if ui - .add_enabled(self.doc.can_redo(), Button::new("Redo")) - .on_hover_text("Redo (Ctrl+Shift+Z)") - .clicked() - { - self.redo(ctx); - } - ui.separator(); - let at = self.view.centre_in_canvas(); - if ui.button("Note").on_hover_text("Add a text node").clicked() { - self.insert_text_node(at, ctx); - } - if ui.button("File").on_hover_text("Add a file node").clicked() { - self.insert_file_node(at, ctx); - } - if ui.button("Link").on_hover_text("Add a link node").clicked() { - self.insert_link_node(at, ctx); - } - if ui.button("Group").on_hover_text("Add a group").clicked() { - self.insert_group(at, ctx); - } - ui.separator(); - if ui.button("−").on_hover_text("Zoom out (Ctrl+-)").clicked() { - self.view.set_zoom(self.view.zoom() / 1.2); - } - if ui - .button(format!("{:.0}%", self.view.zoom() * 100.0)) - .on_hover_text("Actual size (Ctrl+1)") - .clicked() - { - self.view.set_zoom(1.0); - } - if ui.button("+").on_hover_text("Zoom in (Ctrl++)").clicked() { - self.view.set_zoom(self.view.zoom() * 1.2); - } - if ui - .button("⛶") - .on_hover_text("Zoom to fit (Ctrl+0)") - .clicked() - { - self.view.zoom_to_fit(&self.doc.canvas); - } + /// How many edges point at a node that is not on the canvas. + fn dangling_edges(&self) -> usize { + self.doc + .canvas + .edges + .iter() + .filter(|e| { + self.doc.canvas.node(&e.from_node).is_none() + || self.doc.canvas.node(&e.to_node).is_none() + }) + .count() + } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.toggle_value(&mut self.show_inspector, "Inspector"); - ui.toggle_value(&mut self.view.snap_to_grid, "Snap"); - let icon = if self.dark_mode { - "Light theme" - } else { - "Dark theme" - }; - if ui.button(icon).on_hover_text("Switch theme").clicked() { - self.dark_mode = !self.dark_mode; - } - }); - }); + /// The zoom controls, at the far end of the status bar. Laid out + /// right-to-left, so they read the other way round here. + fn zoom_controls(&mut self, ui: &mut Ui) { + if ui + .button("\u{26f6}") + .on_hover_text("Zoom to fit (Ctrl+0)") + .clicked() + { + self.view.zoom_to_fit(&self.doc.canvas); + } + if ui.button("+").on_hover_text("Zoom in (Ctrl++)").clicked() { + self.view.set_zoom(self.view.zoom() * 1.2); + } + if ui + .button(format!("{:.0}%", self.view.zoom() * 100.0)) + .on_hover_text("Actual size (Ctrl+1)") + .clicked() + { + self.view.set_zoom(1.0); + } + if ui + .button("\u{2212}") + .on_hover_text("Zoom out (Ctrl+-)") + .clicked() + { + self.view.set_zoom(self.view.zoom() / 1.2); + } } fn status_bar(&mut self, ui: &mut Ui, palette: &Palette, ctx: &Context) { + // Where a node sits and how big it is, read off rather than typed in: + // the canvas is where it is put, and this is the only place that says + // so in figures. Read before the row is laid out, which borrows self. + let geometry = self + .view + .selection + .only_node() + .and_then(|id| self.doc.canvas.node(id)) + .map(|node| { + format!( + "x {} \u{b7} y {} \u{b7} {} \u{d7} {}", + node.x, node.y, node.width, node.height + ) + }); + ui.horizontal(|ui| { - let dirty = if self.doc.is_dirty() { " •" } else { "" }; + let dirty = if self.doc.is_dirty() { " \u{2022}" } else { "" }; ui.label( RichText::new(format!("{}{dirty}", self.doc.display_name())) .size(12.0) @@ -951,6 +1003,26 @@ impl App { ); ui.separator(); + let selected = self.view.selection.len(); + let summary = format!( + "{} nodes \u{b7} {} edges{}", + self.doc.canvas.nodes.len(), + self.doc.canvas.edges.len(), + if selected > 0 { + format!(" \u{b7} {selected} selected") + } else { + String::new() + } + ); + ui.label(RichText::new(summary).size(12.0).color(palette.text_muted)); + ui.separator(); + + if let Some(geometry) = geometry { + ui.label(RichText::new(geometry).size(12.0).color(palette.text_muted)) + .on_hover_text("Where the selected node is, and how big"); + ui.separator(); + } + // Files edited through their nodes are written by Save along with // the canvas, so say how many are waiting for it. let edited = self.view.files.dirty_count(); @@ -976,18 +1048,7 @@ impl App { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let selected = self.view.selection.len(); - let summary = format!( - "{} nodes · {} edges{}", - self.doc.canvas.nodes.len(), - self.doc.canvas.edges.len(), - if selected > 0 { - format!(" · {selected} selected") - } else { - String::new() - } - ); - ui.label(RichText::new(summary).size(12.0).color(palette.text_muted)); + self.zoom_controls(ui); }); }); } @@ -1116,7 +1177,7 @@ impl eframe::App for App { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); let palette = self.palette(); - ctx.set_visuals(if self.dark_mode { + ctx.set_visuals(if self.settings.dark_mode { egui::Visuals::dark() } else { egui::Visuals::light() @@ -1140,8 +1201,6 @@ impl eframe::App for App { egui::Panel::top("chrome").show(ui, |ui| { self.menu_bar(ui, &ctx); ui.add_space(2.0); - self.toolbar(ui, &ctx); - ui.add_space(2.0); }); egui::Panel::bottom("status").show(ui, |ui| { @@ -1150,21 +1209,6 @@ impl eframe::App for App { ui.add_space(2.0); }); - if self.show_inspector { - egui::Panel::right("inspector") - .default_size(300.0) - .size_range(220.0..=460.0) - .show(ui, |ui| { - let out = inspector::show(ui, &mut self.doc, &mut self.view, &palette); - if let Some(message) = out.status { - self.set_status(message, &ctx); - } - if let Some(target) = out.open_target { - self.open_target(&target, &ctx); - } - }); - } - egui::CentralPanel::default() .frame(egui::Frame::NONE) .show(ui, |ui| { @@ -1178,12 +1222,22 @@ impl eframe::App for App { if let Some((kind, at)) = out.insert { self.insert_requested(kind, at, &ctx); } - if out.reveal_inspector { - self.show_inspector = true; + if let Some(request) = out.pick { + self.pick_file(&request, &ctx); } }); self.dialogs(&ctx); + + // Snapping is set on the view, where the dragging that obeys it is, + // and follows it here so that it is there again next time. + self.settings.snap_to_grid = self.view.snap_to_grid; + } + + /// Keeps the settings across runs. `eframe` calls this on the way out and + /// every so often while the window is open. + fn save(&mut self, storage: &mut dyn eframe::Storage) { + self.settings.store(storage); } } @@ -1195,13 +1249,16 @@ const SHORTCUTS: &[(&str, &str)] = &[ ("Ctrl+V", "Paste nodes, or clipboard text as a node"), ("Ctrl+A", "Select everything"), ("Ctrl+G", "Put the selection in a group"), - ("Ctrl+I", "Show or hide the inspector"), ("Delete", "Delete the selection"), - ("F2", "Edit the selected node"), + ("F2", "Edit the selected node or edge"), ("F", "Zoom to the selection"), ("Escape", "Stop editing, clear the selection"), ("Arrow keys", "Nudge (hold Shift for 10 units)"), ("Double-click", "New note, or edit what is under the cursor"), + ( + "Right-click", + "The menu of the node, edge or canvas under it", + ), ("Middle / right drag", "Pan the canvas"), ("Double-click wheel", "Back to 100%"), ("Wheel", "Zoom around the pointer"), diff --git a/src/app/tests.rs b/src/app/tests.rs index 04ee105..939b187 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -52,16 +52,119 @@ fn harness_with_a_note(text: &str, renderer: Renderer) -> Harness<'static, App> /// The shell is real widgets too, found by the label on them. #[test] -fn the_toolbar_adds_a_note_to_the_canvas() { +fn the_insert_menu_adds_a_note_to_the_canvas() { let mut harness = harness_with_a_note("the only one", Renderer::None); assert_eq!(harness.state().doc.canvas.nodes.len(), 1); - harness.get_by_label("Note").click(); + harness.get_by_label("Insert").click(); + harness.run(); + harness.get_by_label("Text node").click(); harness.run(); assert_eq!(harness.state().doc.canvas.nodes.len(), 2); } +/// The canvas is where a node is put; the footer is where the figures are +/// read off, for the one node selected. +#[test] +fn the_footer_reads_off_the_selected_node_s_position_and_size() { + let mut harness = harness_with_a_note("the only one", Renderer::None); + harness.run(); + assert!( + harness.query_by_label_contains("x 0").is_none(), + "figures were shown with nothing selected" + ); + + harness.state_mut().view.select_node("n"); + harness.run(); + assert!( + harness + .query_by_label("x 0 \u{b7} y 0 \u{b7} 300 \u{d7} 200") + .is_some(), + "the footer said nothing about the selected node" + ); + + // Two nodes have no one position between them. + harness.state_mut().doc.canvas.nodes.push(Node::new( + "m", + NodeKind::Text { + text: String::new(), + }, + 400, + 0, + 100, + 100, + )); + harness.state_mut().view.selection.nodes = vec!["n".into(), "m".into()]; + harness.run(); + assert!( + harness.query_by_label_contains("300 \u{d7} 200").is_none(), + "figures were shown for a selection of two" + ); +} + +/// What the editor looks like is remembered between runs, so that it is not +/// set up again every time it is opened. +#[test] +fn the_settings_survive_a_restart() { + let mut storage = FakeStorage::default(); + { + let mut harness = Harness::builder() + .with_size(egui::vec2(1200.0, 900.0)) + .build_eframe(|cc| App::new(cc, None)); + let app = harness.state_mut(); + app.settings.dark_mode = false; + app.view.snap_to_grid = true; + app.settings.remember(Path::new("/tmp/somewhere.canvas")); + harness.run(); + eframe::App::save(harness.state_mut(), &mut storage); + } + + let settings = Settings::load(Some(&storage)); + assert!(!settings.dark_mode); + assert!(settings.snap_to_grid, "snapping was not carried over"); + assert_eq!(settings.recent, [PathBuf::from("/tmp/somewhere.canvas")]); +} + +/// The canvases opened before are offered again, by name, under File. +#[test] +fn the_file_menu_offers_the_canvases_opened_before() { + let mut harness = harness_with_a_note("the only one", Renderer::None); + harness + .state_mut() + .settings + .remember(Path::new("/tmp/boards/plan.canvas")); + harness.run(); + + // The submenu carries an arrow in its label, so it is matched loosely. + harness.get_by_label("File").click(); + harness.run(); + harness.get_by_label_contains("Open recent").click(); + harness.run(); + + assert!( + harness.query_by_label("plan.canvas").is_some(), + "the canvas opened before was not offered again" + ); +} + +/// Stands in for the file `eframe` keeps the settings in. +#[derive(Default)] +struct FakeStorage(std::collections::HashMap); + +impl eframe::Storage for FakeStorage { + fn get_string(&self, key: &str) -> Option { + self.0.get(key).cloned() + } + fn set_string(&mut self, key: &str, value: String) { + self.0.insert(key.to_owned(), value); + } + fn remove_string(&mut self, key: &str) { + self.0.remove(key); + } + fn flush(&mut self) {} +} + /// The editor window as it is drawn: the text filling it, the ways out /// along the bottom. Run `UPDATE_SNAPSHOTS=1 cargo test` after changing it /// on purpose, and look at `tests/snapshots/*.diff.png` when it fails. diff --git a/src/inspector.rs b/src/inspector.rs deleted file mode 100644 index 5f9f1cc..0000000 --- a/src/inspector.rs +++ /dev/null @@ -1,894 +0,0 @@ -//! The properties panel: everything about the current selection that is easier -//! to change with a widget than by dragging it on the canvas. - -use std::path::{Path, PathBuf}; - -use eframe::egui::{self, Color32, ComboBox, DragValue, RichText, TextEdit, Ui}; - -use crate::document::Document; -use crate::model::{BackgroundStyle, Color, EndShape, Node, NodeKind, Side}; -use crate::theme::{self, Palette}; -use crate::view::CanvasView; - -#[derive(Default)] -pub struct InspectorOutput { - pub status: Option, - pub open_target: Option, -} - -pub fn show( - ui: &mut Ui, - doc: &mut Document, - view: &mut CanvasView, - palette: &Palette, -) -> InspectorOutput { - let mut out = InspectorOutput::default(); - egui::ScrollArea::vertical().show(ui, |ui| { - ui.add_space(4.0); - if let Some(id) = view.selection.only_node().map(str::to_owned) { - node_section(ui, doc, view, palette, &id, &mut out); - } else if let Some(id) = view.selection.only_edge().map(str::to_owned) { - edge_section(ui, doc, view, palette, &id, &mut out); - } else if !view.selection.is_empty() { - multi_section(ui, doc, view, palette, &mut out); - } else { - canvas_section(ui, doc, palette, &mut out); - } - }); - out -} - -// --------------------------------------------------------------------------- -// A single node -// --------------------------------------------------------------------------- - -fn node_section( - ui: &mut Ui, - doc: &mut Document, - view: &mut CanvasView, - palette: &Palette, - id: &str, - out: &mut InspectorOutput, -) { - let Some(node) = doc.canvas.node(id).cloned() else { - return; - }; - heading( - ui, - palette, - &format!("{} node", capitalise(node.kind.type_name())), - ); - - match &node.kind { - NodeKind::Text { text } => { - let mut buffer = text.clone(); - let response = ui.add( - TextEdit::multiline(&mut buffer) - .desired_width(f32::INFINITY) - .desired_rows(6) - .hint_text("Markdown"), - ); - if response.changed() { - doc.begin_change("Edit text"); - if let Some(NodeKind::Text { text }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) - { - *text = buffer; - } - } - if response.lost_focus() { - doc.end_change(); - } - } - NodeKind::File { file, subpath } => { - let mut path = file.clone(); - let mut sub = subpath.clone().unwrap_or_default(); - ui.label(field_label(palette, "File")); - let changed = ui - .add(TextEdit::singleline(&mut path).desired_width(f32::INFINITY)) - .changed(); - ui.horizontal(|ui| { - if ui.button("Browse…").clicked() - && let Some(picked) = pick_any_file(doc) - { - path = picked; - doc.change("Set file"); - set_file(doc, id, Some(path.clone()), None); - } - if ui - .button("Open") - .on_hover_text("Open with the system default application") - .clicked() - { - out.open_target = Some(file.clone()); - } - // Editing happens in a window of its own; this is the way in - // for anyone who has not found the double-click. - if ui - .button("Edit") - .on_hover_text("Change the file itself, in an editor window") - .clicked() - { - out.status = Some(if view.edit_file_contents(doc, id) { - "Editing the file — save the canvas to write it back".to_owned() - } else { - "Only plain-text files can be edited here".to_owned() - }); - } - }); - ui.add_space(6.0); - ui.label(field_label(palette, "Subpath")); - let sub_changed = ui - .add( - TextEdit::singleline(&mut sub) - .hint_text("#heading") - .desired_width(f32::INFINITY), - ) - .changed(); - if changed || sub_changed { - doc.begin_change("Edit file node"); - set_file(doc, id, Some(path), Some(sub)); - } - } - NodeKind::Link { url, label } => { - let mut url_buffer = url.clone(); - ui.label(field_label(palette, "URL")); - if ui - .add(TextEdit::singleline(&mut url_buffer).desired_width(f32::INFINITY)) - .changed() - { - doc.begin_change("Edit URL"); - if let Some(NodeKind::Link { url, .. }) = - doc.canvas.node_mut(id).map(|n| &mut n.kind) - { - *url = url_buffer; - } - } - - ui.add_space(6.0); - ui.label(field_label(palette, "Display text")); - let mut label_buffer = label.clone().unwrap_or_default(); - if ui - .add( - TextEdit::singleline(&mut label_buffer) - .desired_width(f32::INFINITY) - .hint_text("Shown instead of the URL"), - ) - .changed() - { - doc.begin_change("Edit display text"); - if let Some(NodeKind::Link { label, .. }) = - doc.canvas.node_mut(id).map(|n| &mut n.kind) - { - // Kept verbatim so a space typed mid-word is not trimmed - // away under the cursor; blank labels simply go away. - *label = (!label_buffer.is_empty()).then_some(label_buffer); - } - } - - ui.add_space(6.0); - if ui.button("Open in browser").clicked() { - out.open_target = Some(url.clone()); - } - } - NodeKind::Group { - label, - background, - background_style, - } => { - let mut label_buffer = label.clone().unwrap_or_default(); - ui.label(field_label(palette, "Label")); - if ui - .add( - TextEdit::singleline(&mut label_buffer) - .desired_width(f32::INFINITY) - .hint_text("Unnamed group"), - ) - .changed() - { - doc.begin_change("Edit label"); - set_group(doc, id, Some(label_buffer), None, None); - } - - ui.add_space(6.0); - ui.label(field_label(palette, "Background image")); - let mut background_buffer = background.clone().unwrap_or_default(); - if ui - .add(TextEdit::singleline(&mut background_buffer).desired_width(f32::INFINITY)) - .changed() - { - doc.begin_change("Edit background"); - set_group(doc, id, None, Some(background_buffer), None); - } - ui.horizontal(|ui| { - if ui.button("Browse…").clicked() - && let Some(picked) = pick_image_file(doc) - { - doc.change("Set background"); - set_group(doc, id, None, Some(picked), None); - } - if background.is_some() && ui.button("Clear").clicked() { - doc.change("Clear background"); - set_group(doc, id, None, Some(String::new()), None); - } - }); - - ui.add_space(6.0); - let mut style = *background_style; - ComboBox::from_label("Fitting") - .selected_text(style.map_or("default", BackgroundStyle::label)) - .show_ui(ui, |ui| { - ui.selectable_value(&mut style, None, "default"); - for option in BackgroundStyle::ALL { - ui.selectable_value(&mut style, Some(option), option.label()); - } - }); - if style != *background_style { - doc.change("Set background fitting"); - set_group(doc, id, None, None, Some(style)); - } - - let members = doc.canvas.nodes_in_group(id).len(); - ui.add_space(4.0); - ui.label( - RichText::new(format!( - "Contains {members} node{}", - if members == 1 { "" } else { "s" } - )) - .size(12.0) - .color(palette.text_muted), - ); - } - NodeKind::Unknown { type_name } => { - ui.label( - RichText::new(format!( - "This node has the unsupported type “{type_name}”. Its attributes are kept as they are when saving." - )) - .size(12.0) - .color(palette.text_muted), - ); - } - } - - ui.add_space(10.0); - geometry_section(ui, doc, palette, id, &node); - - ui.add_space(10.0); - ui.label(field_label(palette, "Colour")); - if let Some(color) = color_picker(ui, node.color.as_ref(), palette, id) { - doc.change("Set colour"); - if let Some(node) = doc.canvas.node_mut(id) { - node.color = color; - } - } - - ui.add_space(10.0); - ui.horizontal(|ui| { - if ui.button("Bring to front").clicked() { - view.raise_selection(doc, true); - } - if ui.button("Send to back").clicked() { - view.raise_selection(doc, false); - } - }); - ui.add_space(4.0); - if ui - .button(RichText::new("Delete node").color(palette.danger)) - .clicked() - { - out.status = view.delete_selection(doc); - } - - ui.add_space(10.0); - ui.label( - RichText::new(format!("id: {}", node.id)) - .size(11.0) - .monospace() - .color(palette.text_muted), - ); -} - -fn geometry_section(ui: &mut Ui, doc: &mut Document, palette: &Palette, id: &str, node: &Node) { - let (mut x, mut y, mut w, mut h) = (node.x, node.y, node.width, node.height); - ui.label(field_label(palette, "Position and size")); - let mut changed = false; - ui.horizontal(|ui| { - changed |= ui - .add(DragValue::new(&mut x).speed(1).prefix("x ")) - .changed(); - changed |= ui - .add(DragValue::new(&mut y).speed(1).prefix("y ")) - .changed(); - }); - ui.horizontal(|ui| { - changed |= ui - .add( - DragValue::new(&mut w) - .speed(1) - .range(1..=100_000) - .prefix("w "), - ) - .changed(); - changed |= ui - .add( - DragValue::new(&mut h) - .speed(1) - .range(1..=100_000) - .prefix("h "), - ) - .changed(); - }); - if changed { - doc.begin_change("Edit geometry"); - if let Some(node) = doc.canvas.node_mut(id) { - node.x = x; - node.y = y; - node.width = w; - node.height = h; - } - } -} - -// --------------------------------------------------------------------------- -// A single edge -// --------------------------------------------------------------------------- - -fn edge_section( - ui: &mut Ui, - doc: &mut Document, - view: &mut CanvasView, - palette: &Palette, - id: &str, - out: &mut InspectorOutput, -) { - let Some(edge) = doc.canvas.edge(id).cloned() else { - return; - }; - heading(ui, palette, "Edge"); - - let name_of = |node_id: &str| { - doc.canvas - .node(node_id) - .map(|n| n.title()) - .unwrap_or_else(|| format!("missing node {node_id}")) - }; - ui.label( - RichText::new(format!( - "{} → {}", - name_of(&edge.from_node), - name_of(&edge.to_node) - )) - .size(12.0) - .color(palette.text_muted), - ); - - ui.add_space(8.0); - let mut from_side = edge.from_side; - let mut to_side = edge.to_side; - let mut from_end = edge.from_end; - let mut to_end = edge.to_end; - - ui.horizontal(|ui| { - side_combo(ui, "From", &mut from_side); - end_combo(ui, "Start", &mut from_end, EndShape::None); - }); - ui.horizontal(|ui| { - side_combo(ui, "To", &mut to_side); - end_combo(ui, "End", &mut to_end, EndShape::Arrow); - }); - if from_side != edge.from_side - || to_side != edge.to_side - || from_end != edge.from_end - || to_end != edge.to_end - { - doc.change("Edit edge"); - if let Some(edge) = doc.canvas.edge_mut(id) { - edge.from_side = from_side; - edge.to_side = to_side; - edge.from_end = from_end; - edge.to_end = to_end; - } - } - - ui.add_space(8.0); - ui.label(field_label(palette, "Label")); - let mut label = edge.label.clone().unwrap_or_default(); - if ui - .add(TextEdit::singleline(&mut label).desired_width(f32::INFINITY)) - .changed() - { - doc.begin_change("Edit edge label"); - if let Some(edge) = doc.canvas.edge_mut(id) { - edge.label = (!label.is_empty()).then_some(label); - } - } - - ui.add_space(8.0); - ui.label(field_label(palette, "Colour")); - if let Some(color) = color_picker(ui, edge.color.as_ref(), palette, id) { - doc.change("Set colour"); - if let Some(edge) = doc.canvas.edge_mut(id) { - edge.color = color; - } - } - - ui.add_space(10.0); - ui.horizontal(|ui| { - if ui.button("Reverse direction").clicked() { - doc.change("Reverse edge"); - if let Some(edge) = doc.canvas.edge_mut(id) { - std::mem::swap(&mut edge.from_node, &mut edge.to_node); - std::mem::swap(&mut edge.from_side, &mut edge.to_side); - std::mem::swap(&mut edge.from_end, &mut edge.to_end); - } - out.status = Some("Reversed the edge".to_owned()); - } - if ui - .button(RichText::new("Delete edge").color(palette.danger)) - .clicked() - { - out.status = view.delete_selection(doc); - } - }); -} - -fn side_combo(ui: &mut Ui, label: &str, side: &mut Option) { - ComboBox::from_id_salt(label) - .selected_text(format!("{label}: {}", side.map_or("auto", Side::label))) - .width(110.0) - .show_ui(ui, |ui| { - ui.selectable_value(side, None, "auto"); - for option in Side::ALL { - ui.selectable_value(side, Some(option), option.label()); - } - }); -} - -fn end_combo(ui: &mut Ui, label: &str, end: &mut Option, default: EndShape) { - let text = match end { - None => format!("default ({})", end_name(default)), - Some(value) => end_name(*value).to_owned(), - }; - ComboBox::from_id_salt(label) - .selected_text(format!("{label}: {text}")) - .width(130.0) - .show_ui(ui, |ui| { - ui.selectable_value(end, None, format!("default ({})", end_name(default))); - ui.selectable_value(end, Some(EndShape::None), "none"); - ui.selectable_value(end, Some(EndShape::Arrow), "arrow"); - }); -} - -fn end_name(end: EndShape) -> &'static str { - match end { - EndShape::None => "none", - EndShape::Arrow => "arrow", - } -} - -// --------------------------------------------------------------------------- -// Many things selected -// --------------------------------------------------------------------------- - -fn multi_section( - ui: &mut Ui, - doc: &mut Document, - view: &mut CanvasView, - palette: &Palette, - out: &mut InspectorOutput, -) { - let nodes = view.selection.nodes.len(); - let edges = view.selection.edges.len(); - heading(ui, palette, "Multiple items"); - ui.label( - RichText::new(format!( - "{nodes} node{} and {edges} edge{} selected", - plural(nodes), - plural(edges) - )) - .size(12.0) - .color(palette.text_muted), - ); - - ui.add_space(10.0); - ui.label(field_label(palette, "Colour")); - if let Some(color) = color_picker(ui, None, palette, "multi") { - doc.change("Set colour"); - for id in view.selection.nodes.clone() { - if let Some(node) = doc.canvas.node_mut(&id) { - node.color = color.clone(); - } - } - for id in view.selection.edges.clone() { - if let Some(edge) = doc.canvas.edge_mut(&id) { - edge.color = color.clone(); - } - } - } - - if nodes >= 2 { - ui.add_space(10.0); - ui.label(field_label(palette, "Align")); - ui.horizontal(|ui| { - if ui.button("Left").clicked() { - align(doc, view, Align::Left); - } - if ui.button("Centre").clicked() { - align(doc, view, Align::CentreX); - } - if ui.button("Right").clicked() { - align(doc, view, Align::Right); - } - }); - ui.horizontal(|ui| { - if ui.button("Top").clicked() { - align(doc, view, Align::Top); - } - if ui.button("Middle").clicked() { - align(doc, view, Align::CentreY); - } - if ui.button("Bottom").clicked() { - align(doc, view, Align::Bottom); - } - }); - } - - ui.add_space(10.0); - if ui - .button(RichText::new("Delete selection").color(palette.danger)) - .clicked() - { - out.status = view.delete_selection(doc); - } -} - -#[derive(Clone, Copy)] -enum Align { - Left, - CentreX, - Right, - Top, - CentreY, - Bottom, -} - -fn align(doc: &mut Document, view: &CanvasView, how: Align) { - let ids = view.selection.nodes.clone(); - let boxes: Vec<(i32, i32, i32, i32)> = ids - .iter() - .filter_map(|id| doc.canvas.node(id)) - .map(|n| (n.x, n.y, n.width, n.height)) - .collect(); - if boxes.len() < 2 { - return; - } - let left = boxes.iter().map(|b| b.0).min().unwrap_or(0); - let right = boxes.iter().map(|b| b.0 + b.2).max().unwrap_or(0); - let top = boxes.iter().map(|b| b.1).min().unwrap_or(0); - let bottom = boxes.iter().map(|b| b.1 + b.3).max().unwrap_or(0); - - doc.change("Align"); - for id in ids { - let Some(node) = doc.canvas.node_mut(&id) else { - continue; - }; - match how { - Align::Left => node.x = left, - Align::Right => node.x = right - node.width, - Align::CentreX => node.x = (left + right) / 2 - node.width / 2, - Align::Top => node.y = top, - Align::Bottom => node.y = bottom - node.height, - Align::CentreY => node.y = (top + bottom) / 2 - node.height / 2, - } - } -} - -// --------------------------------------------------------------------------- -// Nothing selected -// --------------------------------------------------------------------------- - -fn canvas_section(ui: &mut Ui, doc: &mut Document, palette: &Palette, out: &mut InspectorOutput) { - heading(ui, palette, "Canvas"); - let nodes = doc.canvas.nodes.len(); - let edges = doc.canvas.edges.len(); - let groups = doc.canvas.nodes.iter().filter(|n| n.is_group()).count(); - for (label, value) in [ - ("Nodes", nodes.to_string()), - ("Groups", groups.to_string()), - ("Edges", edges.to_string()), - ( - "File", - doc.path() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "not saved yet".to_owned()), - ), - ] { - ui.horizontal(|ui| { - ui.label(field_label(palette, label)); - ui.label(RichText::new(value).size(12.0)); - }); - } - - let dangling = doc - .canvas - .edges - .iter() - .filter(|e| { - doc.canvas.node(&e.from_node).is_none() || doc.canvas.node(&e.to_node).is_none() - }) - .count(); - if dangling > 0 { - ui.add_space(8.0); - ui.label( - RichText::new(format!( - "{dangling} edge{} point at nodes that do not exist.", - plural(dangling) - )) - .size(12.0) - .color(palette.danger), - ); - if ui.button("Remove them").clicked() { - doc.change("Remove dangling edges"); - let removed = doc.canvas.drop_dangling_edges(); - out.status = Some(format!( - "Removed {removed} dangling edge{}", - plural(removed) - )); - } - } - - ui.add_space(12.0); - ui.label(field_label(palette, "Tips")); - for tip in [ - "Double-click the canvas to add a note", - "Drag a dot on a node's edge to connect it", - "Middle- or right-drag to pan, wheel to zoom", - "Swipe to pan, Ctrl+swipe to zoom", - "Hold Shift to add to the selection", - ] { - ui.label( - RichText::new(format!("• {tip}")) - .size(12.0) - .color(palette.text_muted), - ); - } -} - -// --------------------------------------------------------------------------- -// Shared widgets -// --------------------------------------------------------------------------- - -/// A row of the six preset colours plus a hex field. Returns the new value when -/// the user picked one. -fn color_picker( - ui: &mut Ui, - current: Option<&Color>, - palette: &Palette, - salt: &str, -) -> Option> { - let mut picked = None; - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (index, color) in theme::PRESETS.iter().enumerate() { - let preset = Color::Preset(index as u8 + 1); - let selected = current == Some(&preset); - if swatch(ui, *color, selected, palette).clicked() { - picked = Some(Some(preset)); - } - } - if swatch(ui, palette.surface, current.is_none(), palette) - .on_hover_text("No colour") - .clicked() - { - picked = Some(None); - } - }); - - ui.add_space(4.0); - ui.horizontal(|ui| { - let mut hex = match current { - Some(Color::Hex(hex)) => hex.clone(), - _ => String::new(), - }; - let response = ui.add( - TextEdit::singleline(&mut hex) - .id_salt(format!("hex-{salt}")) - .hint_text("#rrggbb") - .desired_width(90.0), - ); - if response.changed() - && let Some(color) = Color::parse(&hex) - { - picked = Some(Some(color)); - } - if let Some(color) = current { - ui.label( - RichText::new(color.display_name()) - .size(11.0) - .color(palette.text_muted), - ); - } - }); - picked -} - -fn swatch(ui: &mut Ui, color: Color32, selected: bool, palette: &Palette) -> egui::Response { - let size = egui::vec2(20.0, 20.0); - let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); - let stroke = if selected { - egui::Stroke::new(2.0, palette.accent) - } else if response.hovered() { - egui::Stroke::new(1.5, palette.text_muted) - } else { - egui::Stroke::new(1.0, palette.border) - }; - ui.painter().rect( - rect, - egui::CornerRadius::same(4), - color, - stroke, - egui::StrokeKind::Inside, - ); - response -} - -fn heading(ui: &mut Ui, palette: &Palette, text: &str) { - ui.label(RichText::new(text).size(15.0).strong().color(palette.text)); - ui.add_space(6.0); -} - -fn field_label(palette: &Palette, text: &str) -> RichText { - RichText::new(text).size(11.0).color(palette.text_muted) -} - -fn plural(count: usize) -> &'static str { - if count == 1 { "" } else { "s" } -} - -fn capitalise(word: &str) -> String { - let mut chars = word.chars(); - match chars.next() { - Some(first) => first.to_uppercase().collect::() + chars.as_str(), - None => String::new(), - } -} - -fn set_file(doc: &mut Document, id: &str, file: Option, subpath: Option) { - if let Some(NodeKind::File { - file: current, - subpath: current_sub, - }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) - { - if let Some(file) = file { - *current = file; - } - if let Some(sub) = subpath { - *current_sub = (!sub.is_empty()).then_some(sub); - } - } -} - -fn set_group( - doc: &mut Document, - id: &str, - label: Option, - background: Option, - style: Option>, -) { - if let Some(NodeKind::Group { - label: current_label, - background: current_background, - background_style, - }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) - { - if let Some(label) = label { - *current_label = (!label.is_empty()).then_some(label); - } - if let Some(background) = background { - *current_background = (!background.is_empty()).then_some(background); - } - if let Some(style) = style { - *background_style = style; - } - } -} - -fn pick_any_file(doc: &Document) -> Option { - let mut dialog = rfd::FileDialog::new(); - if let Some(dir) = doc.base_dir() { - dialog = dialog.set_directory(dir); - } - dialog - .pick_file() - .map(|path| relative_to(doc.base_dir().as_deref(), &path)) -} - -fn pick_image_file(doc: &Document) -> Option { - let mut dialog = - rfd::FileDialog::new().add_filter("Images", &["png", "jpg", "jpeg", "gif", "bmp", "webp"]); - if let Some(dir) = doc.base_dir() { - dialog = dialog.set_directory(dir); - } - dialog - .pick_file() - .map(|path| relative_to(doc.base_dir().as_deref(), &path)) -} - -/// Paths are stored relative to the canvas when possible, which is what makes a -/// canvas plus its files portable. -pub fn relative_to(base: Option<&Path>, path: &Path) -> String { - let relative = base - .and_then(|base| path.strip_prefix(base).ok()) - .map(PathBuf::from); - let path = relative.as_deref().unwrap_or(path); - path.to_string_lossy().replace('\\', "/") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::NodeKind; - use crate::view::CanvasView; - - fn node(id: &str, x: i32, y: i32, w: i32, h: i32) -> Node { - Node::new( - id, - NodeKind::Text { - text: String::new(), - }, - x, - y, - w, - h, - ) - } - - #[test] - fn relative_paths_are_used_when_the_file_is_below_the_canvas() { - let base = Path::new("/vault"); - assert_eq!( - relative_to(Some(base), Path::new("/vault/img/a.png")), - "img/a.png" - ); - assert_eq!( - relative_to(Some(base), Path::new("/elsewhere/a.png")), - "/elsewhere/a.png" - ); - assert_eq!(relative_to(None, Path::new("/a/b.png")), "/a/b.png"); - } - - #[test] - fn aligning_moves_nodes_to_a_shared_edge() { - let mut doc = Document::new(); - doc.canvas.nodes.push(node("a", 0, 0, 100, 100)); - doc.canvas.nodes.push(node("b", 50, 200, 200, 50)); - let mut view = CanvasView::new(); - view.select_all(&doc.canvas); - - align(&mut doc, &view, Align::Left); - assert_eq!(doc.canvas.node("b").unwrap().x, 0); - - align(&mut doc, &view, Align::Right); - assert_eq!(doc.canvas.node("a").unwrap().x, 100); - assert_eq!(doc.canvas.node("b").unwrap().x, 0); - - align(&mut doc, &view, Align::Top); - assert_eq!(doc.canvas.node("b").unwrap().y, 0); - } - - #[test] - fn aligning_needs_at_least_two_nodes() { - let mut doc = Document::new(); - doc.canvas.nodes.push(node("a", 7, 7, 100, 100)); - let mut view = CanvasView::new(); - view.select_node("a"); - align(&mut doc, &view, Align::Left); - assert_eq!(doc.canvas.node("a").unwrap().x, 7); - assert!(!doc.can_undo(), "a no-op should not create an undo step"); - } - - #[test] - fn capitalises_type_names_for_the_heading() { - assert_eq!(capitalise("text"), "Text"); - assert_eq!(capitalise(""), ""); - } -} diff --git a/src/main.rs b/src/main.rs index ba9931c..f634a1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,10 +9,10 @@ mod app; mod document; mod files; mod geometry; -mod inspector; mod markdown; mod model; mod nodes; +mod settings; mod theme; mod view; @@ -35,6 +35,7 @@ fn main() -> eframe::Result { .with_inner_size([1280.0, 820.0]) .with_min_inner_size([640.0, 420.0]) .with_app_id("jsoncanvas-desktop"), + persist_window: false, ..Default::default() }; diff --git a/src/model.rs b/src/model.rs index b82ebaa..1fce80f 100644 --- a/src/model.rs +++ b/src/model.rs @@ -122,7 +122,7 @@ impl Color { } } - /// Human readable name, for the inspector. + /// Human readable name, for the colour menu. pub fn display_name(&self) -> String { match self { Self::Preset(n) => Self::PRESET_NAMES[(*n as usize) - 1].to_owned(), diff --git a/src/nodes.rs b/src/nodes.rs index 28d4416..2914922 100644 --- a/src/nodes.rs +++ b/src/nodes.rs @@ -40,6 +40,17 @@ pub fn resolve_path(base_dir: Option<&Path>, raw: &str) -> PathBuf { } } +/// Turns an absolute path into the form a node stores it in: relative to the +/// folder the canvas lives in when it is below it, which is what makes a +/// canvas and the files it points at portable together. +pub fn relative_to(base: Option<&Path>, path: &Path) -> String { + let relative = base + .and_then(|base| path.strip_prefix(base).ok()) + .map(PathBuf::from); + let path = relative.as_deref().unwrap_or(path); + path.to_string_lossy().replace('\\', "/") +} + /// A `file://` URI for egui's image loader. /// /// On Windows the loader wants the `file:///C:/dir/file.png` form: without the @@ -323,6 +334,20 @@ mod tests { } } + #[test] + fn relative_paths_are_used_when_the_file_is_below_the_canvas() { + let base = Path::new("/vault"); + assert_eq!( + relative_to(Some(base), Path::new("/vault/img/a.png")), + "img/a.png" + ); + assert_eq!( + relative_to(Some(base), Path::new("/elsewhere/a.png")), + "/elsewhere/a.png" + ); + assert_eq!(relative_to(None, Path::new("/a/b.png")), "/a/b.png"); + } + #[test] fn shows_the_host_of_a_link() { assert_eq!( diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..fd01434 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,113 @@ +//! Settings that belong to the application rather than to any one canvas: +//! what the editor looks like and what was open in it last. +//! +//! They ride along in the store `eframe` keeps for the app — a file under the +//! user's data directory — and are written out when the window closes as well +//! as every so often while it is open. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// The key the settings are filed under in [`eframe::Storage`]. +pub const STORAGE_KEY: &str = "settings"; + +/// How many recently opened canvases to offer. Long enough to cover the ones +/// being worked on, short enough that the menu is still read at a glance. +pub const MAX_RECENT: usize = 10; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +// Settings written by an older version are read with whatever they left out +// filled in from `Default`, so adding a field never throws the rest away. +#[serde(default)] +pub struct Settings { + pub dark_mode: bool, + pub snap_to_grid: bool, + /// Canvases opened before, most recent first. + pub recent: Vec, +} + +impl Default for Settings { + fn default() -> Self { + Self { + dark_mode: true, + snap_to_grid: false, + recent: Vec::new(), + } + } +} + +impl Settings { + /// The stored settings, or the defaults when there are none or they can no + /// longer be read. + pub fn load(storage: Option<&dyn eframe::Storage>) -> Self { + storage + .and_then(|storage| eframe::get_value(storage, STORAGE_KEY)) + .unwrap_or_default() + } + + pub fn store(&self, storage: &mut dyn eframe::Storage) { + eframe::set_value(storage, STORAGE_KEY, self); + } + + /// Puts `path` at the top of the recent list, where it appears once. + pub fn remember(&mut self, path: &Path) { + // Absolute, so that the entry still means the same file after the + // working directory has moved on. + let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + self.recent.retain(|known| known != &path); + self.recent.insert(0, path); + self.recent.truncate(MAX_RECENT); + } + + /// Drops `path` from the recent list, for one that has gone from disk. + pub fn forget(&mut self, path: &Path) { + let canonical = std::fs::canonicalize(path).ok(); + self.recent + .retain(|known| known != path && Some(known) != canonical.as_ref()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_recent_list_holds_each_file_once_and_newest_first() { + let mut settings = Settings::default(); + for name in ["a", "b", "c"] { + settings.remember(Path::new(name)); + } + assert_eq!( + settings.recent, + [PathBuf::from("c"), PathBuf::from("b"), PathBuf::from("a")] + ); + + settings.remember(Path::new("a")); + assert_eq!( + settings.recent, + [PathBuf::from("a"), PathBuf::from("c"), PathBuf::from("b")] + ); + + settings.forget(Path::new("c")); + assert_eq!(settings.recent, [PathBuf::from("a"), PathBuf::from("b")]); + } + + #[test] + fn the_recent_list_stops_growing_at_the_cap() { + let mut settings = Settings::default(); + for index in 0..MAX_RECENT * 2 { + settings.remember(Path::new(&format!("canvas-{index}"))); + } + assert_eq!(settings.recent.len(), MAX_RECENT); + assert_eq!(settings.recent[0], PathBuf::from("canvas-19")); + } + + #[test] + fn settings_written_by_an_older_version_keep_their_defaults() { + let partial: Settings = serde_json::from_str(r#"{"dark_mode": false}"#).unwrap(); + assert!(!partial.dark_mode); + assert!(!partial.snap_to_grid); + assert!(partial.recent.is_empty()); + } +} diff --git a/src/view.rs b/src/view.rs index 52f98f0..29d2924 100644 --- a/src/view.rs +++ b/src/view.rs @@ -6,7 +6,7 @@ use std::ops::Range; use std::path::PathBuf; use eframe::egui::{ - self, Align, Atom, Button, Color32, CornerRadius, CursorIcon, Id, LayerId, Layout, + self, Align, Atom, Button, Color32, ComboBox, CornerRadius, CursorIcon, Id, LayerId, Layout, PointerButton, Pos2, Rect, RichText, Sense, Shape, Stroke, StrokeKind, TextEdit, UiBuilder, Vec2, epaint, pos2, vec2, }; @@ -18,7 +18,9 @@ use crate::geometry::{ anchor, auto_sides, bezier_point, bezier_tangent, bounds_of, curve_between, distance_to_curve, edge_curve, node_rect, }; -use crate::model::{Canvas, Color, Edge, EndShape, Extra, MIN_NODE_SIZE, Node, NodeKind, Side}; +use crate::model::{ + BackgroundStyle, Canvas, Color, Edge, EndShape, Extra, MIN_NODE_SIZE, Node, NodeKind, Side, +}; use crate::nodes::{self, BodyContext, FileKind}; use crate::theme::{self, Palette}; @@ -43,6 +45,8 @@ const NODE_CORNER: f32 = 6.0; /// The size the editor window opens at, and what its placement assumes it to /// be; from there it is the user's to resize. const EDITOR_SIZE: Vec2 = vec2(520.0, 380.0); +/// How wide the window holding a node's or an edge's fields is. +const FIELDS_WIDTH: f32 = 300.0; // --------------------------------------------------------------------------- // Selection @@ -232,7 +236,7 @@ enum Gesture { /// A node the user asked for from the canvas context menu. Insertion goes /// through the application because it is the shell that owns the file dialog a -/// file node needs and the inspector a fresh link node sends the user to. +/// file node needs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InsertRequest { Text, @@ -241,6 +245,17 @@ pub enum InsertRequest { Group, } +/// A file the user asked to choose for a node. Like [`InsertRequest`] it goes +/// to the application, which owns the file dialog and the folder the canvas +/// lives in that the chosen path is stored relative to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PickRequest { + /// The file a file node points at. + NodeFile(String), + /// The background image of a group. + GroupBackground(String), +} + /// What the view wants the application to do after a frame. #[derive(Default)] pub struct ViewOutput { @@ -250,18 +265,8 @@ pub struct ViewOutput { pub status: Option, /// A node to add, and where in the canvas it goes. pub insert: Option<(InsertRequest, Pos2)>, - /// Whether to show the inspector, for a node whose fields live there. - pub reveal_inspector: bool, -} - -/// Where an edit request landed. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EditTarget { - /// The editor took it: a window, or a group's label on the canvas. - Editor, - /// The node has nothing to type into. The message says where its fields - /// are instead. - Inspector(&'static str), + /// A file to choose, for the node that asked for one. + pub pick: Option, } // --------------------------------------------------------------------------- @@ -275,6 +280,11 @@ pub struct CanvasView { gesture: Gesture, /// Node the editor is open on. editing: Option, + /// Whether that node is a group whose label is being typed on the canvas, + /// rather than one whose fields are being changed in a window. + editing_label: bool, + /// Edge the edge editor is open on. + editing_edge: Option, /// The text an open file-node editor is working on, while one is open. file_edit: Option, /// Set for one frame to move keyboard focus into a freshly opened editor. @@ -294,6 +304,9 @@ pub struct CanvasView { /// pointer wanders off while the menu is up, so a node added from it has to /// land where the menu was opened and not where the click on the entry was. menu_at: Option, + /// Edge the open context menu was summoned on, `None` when the right + /// click landed on bare canvas. + menu_edge: Option, /// Screen rectangle the canvas was drawn in last frame, `None` until the /// canvas has been laid out once. viewport: Option, @@ -347,6 +360,8 @@ impl CanvasView { selection: Selection::default(), gesture: Gesture::None, editing: None, + editing_label: false, + editing_edge: None, file_edit: None, focus_editor: false, hovered_edge: None, @@ -356,6 +371,7 @@ impl CanvasView { files: FileStore::default(), body_heights: HashMap::new(), menu_at: None, + menu_edge: None, viewport: None, pending_fit: None, scroll_is_touchpad: false, @@ -370,6 +386,8 @@ impl CanvasView { /// in [`CanvasView::files`], waiting to be saved. pub fn stop_editing(&mut self) { self.editing = None; + self.editing_label = false; + self.editing_edge = None; self.file_edit = None; } @@ -409,43 +427,24 @@ impl CanvasView { /// Opens the editor on a text or file node, or on a group's label. pub fn edit_node(&mut self, id: &str) { self.editing = Some(id.to_owned()); + self.editing_label = false; + self.editing_edge = None; self.focus_editor = true; } - /// Takes an edit request on any node, reporting where the editing has to - /// happen: here, or in the inspector for a node with no text in it. - pub fn begin_edit(&mut self, doc: &Document, id: &str) -> EditTarget { - let Some(node) = doc.canvas.node(id) else { - return EditTarget::Editor; - }; - let reason = match &node.kind { - NodeKind::Text { .. } | NodeKind::Group { .. } => None, - NodeKind::File { .. } if self.can_edit_file(doc, id) => None, - NodeKind::File { .. } => Some( - "Only plain-text files are edited here \u{2014} the file and subpath are in the inspector", - ), - NodeKind::Link { .. } => Some("A link node's address and label are in the inspector"), - NodeKind::Unknown { .. } => { - Some("This node type is not one this editor knows \u{2014} it is kept as it stands") - } - }; - match reason { - Some(reason) => EditTarget::Inspector(reason), - None => { - self.edit_node(id); - EditTarget::Editor - } - } + /// Opens a group's label where it is drawn, which is where it reads. + pub fn edit_group_label(&mut self, id: &str) { + self.edit_node(id); + self.editing_label = true; } - /// Opens the editor on the file behind a file node, reporting `false` - /// for a file this editor will not change. - pub fn edit_file_contents(&mut self, doc: &Document, id: &str) -> bool { - let can = self.can_edit_file(doc, id); - if can { - self.edit_node(id); - } - can + /// Opens the editor on an edge: its label, the sides it meets and the + /// shape of its ends. + pub fn edit_edge(&mut self, id: &str) { + self.editing_edge = Some(id.to_owned()); + self.editing = None; + self.file_edit = None; + self.focus_editor = true; } pub fn centre_in_canvas(&self) -> Pos2 { @@ -692,6 +691,54 @@ impl CanvasView { } } + /// Lines the selected nodes up along one edge of the box around them all. + pub fn align_selection(&mut self, doc: &mut Document, how: Alignment) { + let ids = self.selection.nodes.clone(); + let boxes: Vec<(i32, i32, i32, i32)> = ids + .iter() + .filter_map(|id| doc.canvas.node(id)) + .map(|n| (n.x, n.y, n.width, n.height)) + .collect(); + // One node is already lined up with itself. + if boxes.len() < 2 { + return; + } + let left = boxes.iter().map(|b| b.0).min().unwrap_or(0); + let right = boxes.iter().map(|b| b.0 + b.2).max().unwrap_or(0); + let top = boxes.iter().map(|b| b.1).min().unwrap_or(0); + let bottom = boxes.iter().map(|b| b.1 + b.3).max().unwrap_or(0); + + doc.change("Align"); + for id in ids { + let Some(node) = doc.canvas.node_mut(&id) else { + continue; + }; + match how { + Alignment::Left => node.x = left, + Alignment::Right => node.x = right - node.width, + Alignment::CentreX => node.x = (left + right) / 2 - node.width / 2, + Alignment::Top => node.y = top, + Alignment::Bottom => node.y = bottom - node.height, + Alignment::CentreY => node.y = (top + bottom) / 2 - node.height / 2, + } + } + } + + /// Puts the file a file node points at, or a group's background image, to + /// the path the application came back from its file dialog with. + pub fn apply_pick(&mut self, doc: &mut Document, request: &PickRequest, chosen: &str) { + match request { + PickRequest::NodeFile(id) => { + doc.change("Set file"); + set_file(doc, id, Some(chosen.to_owned()), None); + } + PickRequest::GroupBackground(id) => { + doc.change("Set background"); + set_group(doc, id, None, Some(chosen.to_owned()), None); + } + } + } + /// Selected node ids plus the members of any selected group. fn with_group_members(&self, canvas: &Canvas, ids: &[String]) -> Vec { let mut all = ids.to_vec(); @@ -756,7 +803,7 @@ impl CanvasView { // panned away from where it started. The correction is the visible // area and not a pixel more: the layer paints above the panels, so a // background reaching past the central panel would sit on top of the - // inspector and swallow the clicks meant for its widgets. + // panel and swallow the clicks meant for its widgets. let visible = self.to_global.inverse() * outer; scene.set_clip_rect(visible); let background = scene.interact( @@ -796,16 +843,63 @@ impl CanvasView { ); // Nodes are drawn over the background and so take the right click - // themselves; what reaches the background is bare canvas. + // themselves; what reaches the background is bare canvas, or an edge + // painted on it. let at = self.menu_at.unwrap_or_else(|| self.centre_in_canvas()); - background.context_menu(|ui| background_menu(ui, at, &mut output)); + let on_edge = self + .menu_edge + .clone() + .filter(|id| doc.canvas.edge(id).is_some()); + let mut edge_action = None; + background.context_menu(|ui| match &on_edge { + Some(id) => { + let color = doc.canvas.edge(id).and_then(|edge| edge.color.clone()); + edge_menu(ui, palette, color.as_ref(), &mut edge_action); + } + None => background_menu(ui, at, &mut output), + }); + if let (Some(action), Some(id)) = (edge_action, on_edge) { + self.apply_edge_action(doc, &id, action, &mut output); + } - // Last: the editor offers to fit the card to its contents, and how + // Last: the editors offer to fit the card to its contents, and how // tall those are is only known once the card has been drawn. - self.show_edit_window(ui.ctx(), doc, palette); + self.show_edit_window(ui.ctx(), doc, palette, &mut output); + self.show_edge_window(ui.ctx(), doc, palette); output } + fn apply_edge_action( + &mut self, + doc: &mut Document, + id: &str, + action: EdgeAction, + output: &mut ViewOutput, + ) { + match action { + EdgeAction::Edit => self.edit_edge(id), + EdgeAction::SetColor(color) => { + doc.change("Set colour"); + if let Some(edge) = doc.canvas.edge_mut(id) { + edge.color = color; + } + } + EdgeAction::Reverse => { + doc.change("Reverse edge"); + if let Some(edge) = doc.canvas.edge_mut(id) { + std::mem::swap(&mut edge.from_node, &mut edge.to_node); + std::mem::swap(&mut edge.from_side, &mut edge.to_side); + std::mem::swap(&mut edge.from_end, &mut edge.to_end); + } + output.status = Some("Reversed the edge".to_owned()); + } + EdgeAction::Delete => { + self.selection.set_edge(id); + output.status = self.delete_selection(doc); + } + } + } + // -- input ------------------------------------------------------------ /// Panning is a drag or a two-finger swipe; the wheel zooms, and so does @@ -1080,7 +1174,7 @@ impl CanvasView { if response.secondary_clicked() && !self.selection.has_node(&id) { self.selection.set_node(&id); } - let color = doc.canvas.nodes[index].color.clone(); + let node = doc.canvas.nodes[index].clone(); let selected = self.selection.nodes.len(); // The menu only records what was asked for. Deleting or reordering // nodes here would pull the canvas out from under this very loop, @@ -1090,7 +1184,7 @@ impl CanvasView { node_menu( ui, palette, - color.as_ref(), + &node, selected, can_fit, &mut interactions.menu, @@ -1161,7 +1255,7 @@ impl CanvasView { palette: &Palette, ) { let id = doc.canvas.nodes[index].id.clone(); - let editing = self.editing.as_deref() == Some(id.as_str()); + let editing = self.editing_label && self.editing.as_deref() == Some(id.as_str()); let label_rect = Rect::from_min_size( rect.left_top() + vec2(8.0, -26.0), vec2(rect.width().min(320.0) - 16.0, 22.0), @@ -1219,7 +1313,13 @@ impl CanvasView { /// and it shows every keystroke as it lands. /// /// A group's label is still edited on the canvas: one line fits there. - fn show_edit_window(&mut self, ctx: &egui::Context, doc: &mut Document, palette: &Palette) { + fn show_edit_window( + &mut self, + ctx: &egui::Context, + doc: &mut Document, + palette: &Palette, + output: &mut ViewOutput, + ) { let Some(id) = self.editing.clone() else { self.file_edit = None; return; @@ -1230,10 +1330,270 @@ impl CanvasView { }; match doc.canvas.nodes[index].kind { NodeKind::Text { .. } => self.edit_text(ctx, doc, index, palette), - NodeKind::File { .. } => self.edit_file(ctx, doc, index, palette), - NodeKind::Group { .. } => {} - // Nothing in a link or an unknown node is typed in here. - _ => self.stop_editing(), + NodeKind::File { .. } => self.edit_file(ctx, doc, index, palette, output), + // A group being renamed is typed on the canvas, where the label + // reads; the window is for the fields that have nowhere to show. + NodeKind::Group { .. } if self.editing_label => {} + NodeKind::Group { .. } => self.edit_group(ctx, doc, index, palette, output), + NodeKind::Link { .. } => self.edit_link(ctx, doc, index, palette), + NodeKind::Unknown { .. } => self.edit_unknown(ctx, doc, index, palette), + } + } + + /// The fields of a link node, which has no text of its own to type into. + fn edit_link( + &mut self, + ctx: &egui::Context, + doc: &mut Document, + index: usize, + palette: &Palette, + ) { + let NodeKind::Link { url, label } = &doc.canvas.nodes[index].kind else { + return; + }; + let (mut url, mut label) = (url.clone(), label.clone().unwrap_or_default()); + let focus = std::mem::take(&mut self.focus_editor); + let at = focus.then(|| self.editor_pos(&doc.canvas.nodes[index])); + let mut edited: Option<&'static str> = None; + + let (open, close) = fields_window(ctx, "Edit link", at, |ui| { + ui.label(field_label(palette, "URL")); + let response = ui.add( + TextEdit::singleline(&mut url) + .hint_text("https://") + .desired_width(f32::INFINITY), + ); + if focus { + response.request_focus(); + } + if response.changed() { + edited = Some("Edit URL"); + } + + ui.add_space(6.0); + ui.label(field_label(palette, "Display text")); + if ui + .add( + TextEdit::singleline(&mut label) + .hint_text("Shown instead of the URL") + .desired_width(f32::INFINITY), + ) + .changed() + { + edited = Some("Edit display text"); + } + }); + + if let Some(what) = edited { + doc.begin_change(what); + if let NodeKind::Link { + url: current, + label: current_label, + } = &mut doc.canvas.nodes[index].kind + { + *current = url; + // Kept verbatim so a space typed mid-word is not trimmed away + // under the cursor; a blank label simply goes away. + *current_label = (!label.is_empty()).then_some(label); + } + } + if close || !open { + self.stop_editing(); + doc.end_change(); + } + } + + /// The fields of a group that are not its label: the image behind it and + /// how that image fills it. + fn edit_group( + &mut self, + ctx: &egui::Context, + doc: &mut Document, + index: usize, + palette: &Palette, + output: &mut ViewOutput, + ) { + let NodeKind::Group { + label, + background, + background_style, + } = &doc.canvas.nodes[index].kind + else { + return; + }; + let id = doc.canvas.nodes[index].id.clone(); + let mut label_buffer = label.clone().unwrap_or_default(); + let background = background.clone(); + let original_style = *background_style; + let mut style = original_style; + let focus = std::mem::take(&mut self.focus_editor); + let at = focus.then(|| self.editor_pos(&doc.canvas.nodes[index])); + let (mut label_edited, mut clear) = (false, false); + + let (open, close) = fields_window(ctx, "Edit group", at, |ui| { + ui.label(field_label(palette, "Label")); + let response = ui.add( + TextEdit::singleline(&mut label_buffer) + .hint_text("Unnamed group") + .desired_width(f32::INFINITY), + ); + if focus { + response.request_focus(); + } + label_edited = response.changed(); + + ui.add_space(6.0); + ui.label(field_label(palette, "Background image")); + ui.label( + RichText::new(background.clone().unwrap_or_else(|| "none".to_owned())) + .size(12.0) + .color(palette.text_muted), + ); + ui.horizontal(|ui| { + if ui.button("Choose\u{2026}").clicked() { + output.pick = Some(PickRequest::GroupBackground(id.clone())); + } + clear = ui + .add_enabled(background.is_some(), Button::new("Clear")) + .clicked(); + }); + + ui.add_space(6.0); + ComboBox::from_label("Fitting") + .selected_text(style.map_or("default", BackgroundStyle::label)) + .show_ui(ui, |ui| { + ui.selectable_value(&mut style, None, "default"); + for option in BackgroundStyle::ALL { + ui.selectable_value(&mut style, Some(option), option.label()); + } + }); + }); + + if label_edited { + doc.begin_change("Edit label"); + set_group(doc, &id, Some(label_buffer), None, None); + } + if clear { + doc.change("Clear background"); + set_group(doc, &id, None, Some(String::new()), None); + } + if style != original_style { + doc.change("Set background fitting"); + set_group(doc, &id, None, None, Some(style)); + } + if close || !open { + self.stop_editing(); + doc.end_change(); + } + } + + /// A node of a type this editor knows nothing about. Its own attributes + /// are left alone; what every node has can still be changed. + fn edit_unknown( + &mut self, + ctx: &egui::Context, + doc: &mut Document, + index: usize, + palette: &Palette, + ) { + let NodeKind::Unknown { type_name } = &doc.canvas.nodes[index].kind else { + return; + }; + let type_name = type_name.clone(); + let at = std::mem::take(&mut self.focus_editor) + .then(|| self.editor_pos(&doc.canvas.nodes[index])); + let (open, close) = fields_window(ctx, "Edit node", at, |ui| { + ui.label( + RichText::new(format!( + "This node has the unsupported type \u{201c}{type_name}\u{201d}. Its \ + attributes are kept as they are when saving." + )) + .size(12.0) + .color(palette.text_muted), + ); + }); + if close || !open { + self.stop_editing(); + doc.end_change(); + } + } + + /// The editor for an edge: everything about it that is not where its two + /// ends are, which is set by dragging them. + fn show_edge_window(&mut self, ctx: &egui::Context, doc: &mut Document, palette: &Palette) { + let Some(id) = self.editing_edge.clone() else { + return; + }; + let Some(edge) = doc.canvas.edge(&id).cloned() else { + self.editing_edge = None; + return; + }; + let name_of = |node_id: &str| { + doc.canvas + .node(node_id) + .map(|n| n.title()) + .unwrap_or_else(|| format!("missing node {node_id}")) + }; + let ends = format!( + "{} \u{2192} {}", + name_of(&edge.from_node), + name_of(&edge.to_node) + ); + + let mut label = edge.label.clone().unwrap_or_default(); + let (mut from_side, mut to_side) = (edge.from_side, edge.to_side); + let (mut from_end, mut to_end) = (edge.from_end, edge.to_end); + let focus = std::mem::take(&mut self.focus_editor); + let mut label_edited = false; + + let (open, close) = fields_window(ctx, "Edit edge", None, |ui| { + ui.label(RichText::new(ends).size(12.0).color(palette.text_muted)); + + ui.add_space(8.0); + ui.label(field_label(palette, "Label")); + let response = ui.add( + TextEdit::singleline(&mut label) + .hint_text("Shown on the line") + .desired_width(f32::INFINITY), + ); + if focus { + response.request_focus(); + } + label_edited = response.changed(); + + ui.add_space(8.0); + ui.horizontal(|ui| { + side_combo(ui, "From", &mut from_side); + end_combo(ui, "Start", &mut from_end, EndShape::None); + }); + ui.horizontal(|ui| { + side_combo(ui, "To", &mut to_side); + end_combo(ui, "End", &mut to_end, EndShape::Arrow); + }); + }); + + if label_edited { + doc.begin_change("Edit edge label"); + if let Some(edge) = doc.canvas.edge_mut(&id) { + edge.label = (!label.is_empty()).then_some(label); + } + } + if from_side != edge.from_side + || to_side != edge.to_side + || from_end != edge.from_end + || to_end != edge.to_end + { + doc.change("Edit edge"); + if let Some(edge) = doc.canvas.edge_mut(&id) { + edge.from_side = from_side; + edge.to_side = to_side; + edge.from_end = from_end; + edge.to_end = to_end; + } + } + if close || !open { + self.editing_edge = None; + doc.end_change(); } } @@ -1261,7 +1621,7 @@ impl CanvasView { palette, }; let (open, pass) = editor_window(ctx, "Edit text", at, |ui| { - editor_contents(ui, &mut buffer, editor) + editor_contents(ui, &mut buffer, editor, None) }); if pass.changed { @@ -1293,6 +1653,7 @@ impl CanvasView { doc: &mut Document, index: usize, palette: &Palette, + output: &mut ViewOutput, ) { let node = &doc.canvas.nodes[index]; let NodeKind::File { file, subpath } = &node.kind else { @@ -1308,8 +1669,11 @@ impl CanvasView { .as_ref() .is_none_or(|edit| edit.id != id || edit.path != path) { + // A file this editor cannot change still has a node pointing at + // it, and that node's own fields are edited the same as any other. let Some((section, buffer)) = self.readable_part(&path, subpath.as_deref()) else { - self.stop_editing(); + self.file_edit = None; + self.edit_file_fields(ctx, doc, index, palette, output); return; }; self.file_edit = Some(FileEdit { @@ -1339,6 +1703,9 @@ impl CanvasView { content_height: fits, palette, }; + let node = doc.canvas.nodes[index].clone(); + let mut sub_buffer = subpath.clone().unwrap_or_default(); + let mut fields = FileFields::default(); // The buffer and the store are both borrowed out of `self` here, so // everything that needs the view whole waits until after this block. @@ -1350,7 +1717,14 @@ impl CanvasView { return; }; let (open, pass) = editor_window(ctx, &title, at, |ui| { - editor_contents(ui, &mut edit.buffer, editor) + editor_contents( + ui, + &mut edit.buffer, + editor, + Some(&mut |ui: &mut egui::Ui| { + fields = file_fields(ui, palette, &node.kind, &mut sub_buffer); + }), + ) }); if pass.changed { match edit.section.clone() { @@ -1370,6 +1744,21 @@ impl CanvasView { (open, pass) }; + if fields.changed { + doc.begin_change("Edit subpath"); + set_file(doc, &id, None, Some(sub_buffer)); + } + if fields.settled { + // The buffer was taken from the section the old subpath named, so + // it is read again from the one the new subpath now names. Waiting + // for the field to be left keeps the editor from being torn down + // and put back on every keystroke of a half-typed heading. + doc.end_change(); + self.file_edit = None; + } + if fields.pick { + output.pick = Some(PickRequest::NodeFile(id.clone())); + } if pass.fit && let Some(height) = fits { @@ -1380,6 +1769,50 @@ impl CanvasView { } } + /// The fields of a file node whose file this editor will not change: the + /// file itself, the section of it the node shows, and where the card sits. + fn edit_file_fields( + &mut self, + ctx: &egui::Context, + doc: &mut Document, + index: usize, + palette: &Palette, + output: &mut ViewOutput, + ) { + let node = doc.canvas.nodes[index].clone(); + let at = std::mem::take(&mut self.focus_editor).then(|| self.editor_pos(&node)); + let NodeKind::File { file, subpath } = &node.kind else { + return; + }; + let mut sub_buffer = subpath.clone().unwrap_or_default(); + let mut fields = FileFields::default(); + + let title = format!("Edit {}", crate::model::file_name(file)); + let (open, close) = fields_window(ctx, &title, at, |ui| { + ui.label( + RichText::new("This file is not plain text, so it is shown but not edited here.") + .size(12.0) + .color(palette.text_muted), + ); + fields = file_fields(ui, palette, &node.kind, &mut sub_buffer); + }); + + if fields.changed { + doc.begin_change("Edit subpath"); + set_file(doc, &node.id, None, Some(sub_buffer)); + } + if fields.settled { + doc.end_change(); + } + if fields.pick { + output.pick = Some(PickRequest::NodeFile(node.id.clone())); + } + if close || !open { + self.stop_editing(); + doc.end_change(); + } + } + /// Where the editor window goes when it opens on `node`: beside the card, /// so that neither hides the other, and within the viewport. It stays /// wherever it is dragged to from there, until it opens on another card. @@ -1884,23 +2317,54 @@ impl CanvasView { } // -- the context menu + // + // A right click on bare canvas is remembered so that a node added from + // the menu lands there. One on an edge takes the edge over the way a + // click on a node does, and puts the edge's own menu up instead: an + // edge is painted rather than laid out, so there is no widget of its + // own to hang the menu on. if background.secondary_clicked() && let Some(at) = pointer { self.menu_at = Some(at); + self.menu_edge = self.edge_at(&doc.canvas, at); + if let Some(id) = self.menu_edge.clone() { + self.selection.set_edge(&id); + } } match interactions.menu { Some(NodeAction::Edit) => { - if let Some(id) = self.selection.only_node().map(str::to_owned) - && let EditTarget::Inspector(reason) = self.begin_edit(doc, &id) - { - output.reveal_inspector = true; - output.status = Some(reason.to_owned()); + if let Some(id) = self.selection.only_node().map(str::to_owned) { + self.edit_node(&id); } } Some(NodeAction::Fit) => output.status = self.fit_selection(doc), Some(NodeAction::SetColor(color)) => self.set_selection_color(doc, color), Some(NodeAction::Raise(to_front)) => self.raise_selection(doc, to_front), + Some(NodeAction::Align(how)) => self.align_selection(doc, how), + Some(NodeAction::Open) => { + if let Some(target) = self + .selection + .only_node() + .and_then(|id| doc.canvas.node(id)) + .and_then(target_of) + { + output.open_target = Some(target); + } + } + Some(NodeAction::Pick(request)) => output.pick = Some(request), + Some(NodeAction::ClearBackground) => { + if let Some(id) = self.selection.only_node().map(str::to_owned) { + doc.change("Clear background"); + set_group(doc, &id, None, Some(String::new()), None); + } + } + Some(NodeAction::SetBackgroundStyle(style)) => { + if let Some(id) = self.selection.only_node().map(str::to_owned) { + doc.change("Set background fitting"); + set_group(doc, &id, None, None, Some(style)); + } + } Some(NodeAction::Delete) => output.status = self.delete_selection(doc), None => {} } @@ -1913,7 +2377,8 @@ impl CanvasView { return; }; match &node.kind { - NodeKind::Text { .. } | NodeKind::Group { .. } => self.edit_node(id), + NodeKind::Text { .. } => self.edit_node(id), + NodeKind::Group { .. } => self.edit_group_label(id), // A plain-text file is edited here; anything else goes to the // application that knows what to do with it. Either way the file // name in the node's header still opens it outside. @@ -2085,7 +2550,7 @@ enum Pass { } /// What a node's context menu asked for. It is applied once every node has -/// been drawn, because all three commands rearrange the node list. +/// been drawn, because several of the commands rearrange the node list. #[derive(Debug, Clone, PartialEq)] enum NodeAction { /// Open the editor on the one node the menu was summoned from. @@ -2095,9 +2560,41 @@ enum NodeAction { SetColor(Option), /// `true` brings the selection to the front, `false` sends it to the back. Raise(bool), + /// Line the selected nodes up along a shared edge. + Align(Alignment), + /// Open the node's target outside this editor. + Open, + /// Choose the file a file node points at, or a group's background image. + Pick(PickRequest), + /// Take the background image off a group. + ClearBackground, + /// How a group's background image fills the group. + SetBackgroundStyle(Option), Delete, } +/// What an edge's context menu asked for. +#[derive(Debug, Clone, PartialEq)] +enum EdgeAction { + /// Open the editor on the edge the menu was summoned from. + Edit, + SetColor(Option), + /// Swap which end is the start, arrowheads and all. + Reverse, + Delete, +} + +/// The edge of a bounding box the selection is lined up against. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Alignment { + Left, + CentreX, + Right, + Top, + CentreY, + Bottom, +} + /// What happened to nodes during one frame. #[derive(Default)] struct NodeInteractions { @@ -2143,26 +2640,30 @@ fn background_menu(ui: &mut egui::Ui, at: Pos2, output: &mut ViewOutput) { insert(ui, "Group", InsertRequest::Group); } -/// The menu of a right click on a node. `current` is the colour of the node -/// that was clicked and `selected` how many nodes the commands will act on. +/// Everything the context menu of a node offers. `node` is the one the menu +/// was summoned from and `selected` how many nodes the commands will act on. fn node_menu( ui: &mut egui::Ui, palette: &Palette, - current: Option<&Color>, + node: &Node, selected: usize, can_fit: bool, action: &mut Option, ) { + let single = selected <= 1; // Double-clicking does this too, which is not something a node gives away. - if selected <= 1 + if single && ui - .button("Edit") + .button("Edit\u{2026}") .on_hover_text("Open the editor on this node") .clicked() { *action = Some(NodeAction::Edit); ui.close(); } + if single { + kind_items(ui, node, action); + } if ui .add_enabled(can_fit, Button::new("Fit to text")) .on_hover_text("Size the card to show all of the text, and no more") @@ -2174,19 +2675,9 @@ fn node_menu( } ui.separator(); ui.menu_button("Colour", |ui| { - for (index, name) in Color::PRESET_NAMES.iter().enumerate() { - let preset = Color::Preset(index as u8 + 1); - let chosen = current == Some(&preset); - if color_item(ui, palette, theme::PRESETS[index], name, chosen) { - *action = Some(NodeAction::SetColor(Some(preset))); - ui.close(); - } - } - ui.separator(); - if color_item(ui, palette, palette.surface, "none", current.is_none()) { - *action = Some(NodeAction::SetColor(None)); - ui.close(); - } + color_menu(ui, palette, node.color.as_ref(), &mut |color| { + *action = Some(NodeAction::SetColor(color)); + }); }); ui.separator(); @@ -2198,6 +2689,32 @@ fn node_menu( *action = Some(NodeAction::Raise(false)); ui.close(); } + // Lining things up needs something to line them up against. + if selected >= 2 { + ui.menu_button("Align", |ui| { + for (label, how) in [ + ("Left", Alignment::Left), + ("Centre", Alignment::CentreX), + ("Right", Alignment::Right), + ] { + if ui.button(label).clicked() { + *action = Some(NodeAction::Align(how)); + ui.close(); + } + } + ui.separator(); + for (label, how) in [ + ("Top", Alignment::Top), + ("Middle", Alignment::CentreY), + ("Bottom", Alignment::Bottom), + ] { + if ui.button(label).clicked() { + *action = Some(NodeAction::Align(how)); + ui.close(); + } + } + }); + } ui.separator(); let label = match selected { @@ -2213,6 +2730,158 @@ fn node_menu( } } +/// The entries only one kind of node has: what to do with what it points at. +fn kind_items(ui: &mut egui::Ui, node: &Node, action: &mut Option) { + match &node.kind { + NodeKind::File { .. } => { + if ui + .button("Open outside\u{2026}") + .on_hover_text("Open the file with the system default application") + .clicked() + { + *action = Some(NodeAction::Open); + ui.close(); + } + if ui.button("Change file\u{2026}").clicked() { + *action = Some(NodeAction::Pick(PickRequest::NodeFile(node.id.clone()))); + ui.close(); + } + } + NodeKind::Link { .. } => { + if ui.button("Open in browser").clicked() { + *action = Some(NodeAction::Open); + ui.close(); + } + } + NodeKind::Group { + background, + background_style, + .. + } => { + let has_background = background.is_some(); + let style = *background_style; + ui.menu_button("Background", |ui| { + if ui.button("Choose image\u{2026}").clicked() { + *action = Some(NodeAction::Pick(PickRequest::GroupBackground( + node.id.clone(), + ))); + ui.close(); + } + if ui + .add_enabled(has_background, Button::new("Clear")) + .clicked() + { + *action = Some(NodeAction::ClearBackground); + ui.close(); + } + ui.separator(); + if ui + .selectable_label(style.is_none(), "Fitting: default") + .clicked() + { + *action = Some(NodeAction::SetBackgroundStyle(None)); + ui.close(); + } + for option in BackgroundStyle::ALL { + if ui + .selectable_label( + style == Some(option), + format!("Fitting: {}", option.label()), + ) + .clicked() + { + *action = Some(NodeAction::SetBackgroundStyle(Some(option))); + ui.close(); + } + } + }); + } + NodeKind::Text { .. } | NodeKind::Unknown { .. } => {} + } +} + +/// The menu of a right click on an edge, which is the only way to reach one: +/// an edge is painted rather than laid out, so it has no widget of its own. +fn edge_menu( + ui: &mut egui::Ui, + palette: &Palette, + current: Option<&Color>, + action: &mut Option, +) { + if ui + .button("Edit\u{2026}") + .on_hover_text("Label, the sides it meets, and the shape of its ends") + .clicked() + { + *action = Some(EdgeAction::Edit); + ui.close(); + } + ui.separator(); + ui.menu_button("Colour", |ui| { + color_menu(ui, palette, current, &mut |color| { + *action = Some(EdgeAction::SetColor(color)); + }); + }); + if ui.button("Reverse direction").clicked() { + *action = Some(EdgeAction::Reverse); + ui.close(); + } + ui.separator(); + if ui + .button(RichText::new("Delete edge").color(palette.danger)) + .clicked() + { + *action = Some(EdgeAction::Delete); + ui.close(); + } +} + +/// The six preset colours and "none", each with the name the format gives it, +/// and a picker for the rest: the format takes any colour, not just the six it +/// has names for. +fn color_menu( + ui: &mut egui::Ui, + palette: &Palette, + current: Option<&Color>, + picked: &mut dyn FnMut(Option), +) { + for (index, name) in Color::PRESET_NAMES.iter().enumerate() { + let preset = Color::Preset(index as u8 + 1); + let chosen = current == Some(&preset); + if color_item(ui, palette, theme::PRESETS[index], name, chosen) { + picked(Some(preset)); + ui.close(); + } + } + ui.separator(); + if color_item(ui, palette, palette.surface, "none", current.is_none()) { + picked(None); + ui.close(); + } + + ui.separator(); + let mut custom = match current { + Some(color) => palette.resolve(color), + None => palette.accent, + }; + ui.horizontal(|ui| { + // The picker opens a popup of its own inside this menu, so the menu + // has to stay up while a colour is being chosen in it. + if ui.color_edit_button_srgba(&mut custom).changed() { + picked(Some(Color::Hex(format!( + "#{:02x}{:02x}{:02x}", + custom.r(), + custom.g(), + custom.b() + )))); + } + ui.label(match current { + Some(color) => color.display_name(), + None => "Custom".to_owned(), + }); + }); +} + /// One row of the colour submenu: a swatch of the colour, its name, and a ring /// around the swatch the node already carries. fn color_item( @@ -2285,6 +2954,178 @@ fn editor_window( ) } +/// The window the field editors live in: the same corner of the screen as the +/// text editor, sized to whatever it holds. Reports whether the title bar's +/// cross left it open, and whether "Close" was pressed. +fn fields_window( + ctx: &egui::Context, + title: &str, + at: Option, + contents: impl FnOnce(&mut egui::Ui), +) -> (bool, bool) { + let mut open = true; + let mut close = false; + let mut window = egui::Window::new(title) + // A fixed id, so that the window stays where it was put when the + // editor moves to another node and the title changes with it. + .id(Id::new("node-fields")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .default_width(FIELDS_WIDTH); + if let Some(at) = at { + window = window.current_pos(at); + } + window.show(ctx, |ui| { + ui.set_width(FIELDS_WIDTH); + contents(ui); + ui.add_space(10.0); + ui.separator(); + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + close = ui.button("Close").clicked(); + }); + }); + }); + (open, close) +} + +/// What one pass of the fields of a file node produced. +#[derive(Debug, Clone, Copy, Default)] +struct FileFields { + /// Another file was asked for, which only the application can put up the + /// dialog for. + pick: bool, + /// The subpath was typed in this frame. + changed: bool, + /// The subpath field was left, so the section it names can be read. + settled: bool, +} + +/// The file a file node points at and the section of it the node shows. +fn file_fields( + ui: &mut egui::Ui, + palette: &Palette, + kind: &NodeKind, + subpath: &mut String, +) -> FileFields { + let mut out = FileFields::default(); + let NodeKind::File { file, .. } = kind else { + return out; + }; + ui.add_space(10.0); + ui.label(field_label(palette, "File")); + ui.horizontal(|ui| { + // Read, not typed: a path is picked from the folders it is really in, + // which is the only way one that resolves comes out of it. + ui.label(RichText::new(file.as_str()).size(12.0)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + out.pick = ui.button("Change\u{2026}").clicked(); + }); + }); + + ui.add_space(6.0); + ui.label(field_label(palette, "Subpath")); + let response = ui.add( + TextEdit::singleline(subpath) + .hint_text("#heading") + .desired_width(f32::INFINITY), + ); + out.changed = response.changed(); + out.settled = response.lost_focus(); + out +} + +fn side_combo(ui: &mut egui::Ui, label: &str, side: &mut Option) { + ComboBox::from_id_salt(label) + .selected_text(format!("{label}: {}", side.map_or("auto", Side::label))) + .width(110.0) + .show_ui(ui, |ui| { + ui.selectable_value(side, None, "auto"); + for option in Side::ALL { + ui.selectable_value(side, Some(option), option.label()); + } + }); +} + +fn end_combo(ui: &mut egui::Ui, label: &str, end: &mut Option, default: EndShape) { + let text = match end { + None => format!("default ({})", end_name(default)), + Some(value) => end_name(*value).to_owned(), + }; + ComboBox::from_id_salt(label) + .selected_text(format!("{label}: {text}")) + .width(130.0) + .show_ui(ui, |ui| { + ui.selectable_value(end, None, format!("default ({})", end_name(default))); + ui.selectable_value(end, Some(EndShape::None), "none"); + ui.selectable_value(end, Some(EndShape::Arrow), "arrow"); + }); +} + +fn end_name(end: EndShape) -> &'static str { + match end { + EndShape::None => "none", + EndShape::Arrow => "arrow", + } +} + +fn field_label(palette: &Palette, text: &str) -> RichText { + RichText::new(text).size(11.0).color(palette.text_muted) +} + +/// What a node points at outside this editor, if anything. +fn target_of(node: &Node) -> Option { + match &node.kind { + NodeKind::File { file, .. } => Some(file.clone()), + NodeKind::Link { url, .. } => Some(url.clone()), + _ => None, + } +} + +/// Writes the parts of a file node that were given, leaving the rest alone. +fn set_file(doc: &mut Document, id: &str, file: Option, subpath: Option) { + if let Some(NodeKind::File { + file: current, + subpath: current_sub, + }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) + { + if let Some(file) = file { + *current = file; + } + if let Some(sub) = subpath { + *current_sub = (!sub.is_empty()).then_some(sub); + } + } +} + +/// Writes the parts of a group that were given, leaving the rest alone. A +/// blank label or background clears it, which is how it goes out of the file. +fn set_group( + doc: &mut Document, + id: &str, + label: Option, + background: Option, + style: Option>, +) { + if let Some(NodeKind::Group { + label: current_label, + background: current_background, + background_style, + }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) + { + if let Some(label) = label { + *current_label = (!label.is_empty()).then_some(label); + } + if let Some(background) = background { + *current_background = (!background.is_empty()).then_some(background); + } + if let Some(style) = style { + *background_style = style; + } + } +} + /// Everything one editor needs that is not the text being edited. struct Editor<'a> { /// What the text is typed in: the font a text node is read in, or a @@ -2313,12 +3154,18 @@ struct EditorPass { } /// The text, filling the window and scrolling when it outgrows it, with the -/// actions along the bottom. -fn editor_contents(ui: &mut egui::Ui, buffer: &mut String, editor: Editor<'_>) -> EditorPass { +/// fields `details` draws, if any, and the actions along the bottom. +fn editor_contents( + ui: &mut egui::Ui, + buffer: &mut String, + editor: Editor<'_>, + details: Option<&mut dyn FnMut(&mut egui::Ui)>, +) -> EditorPass { let mut pass = EditorPass::default(); - // The actions claim the bottom of the window before the text takes - // everything that is left of it. + // The actions and the fields claim the bottom of the window before the + // text takes everything that is left of it. The actions go first, so the + // strip a user aims for stays against the bottom edge. egui::Panel::bottom("editor-actions").show(ui, |ui| { ui.add_space(6.0); // The row of a horizontal layout, so that the right-to-left one inside @@ -2338,6 +3185,14 @@ fn editor_contents(ui: &mut egui::Ui, buffer: &mut String, editor: Editor<'_>) - }); ui.add_space(2.0); }); + // No panel at all when there is nothing to put in it, so that the text + // of a note runs down to the actions with no empty strip in between. + if let Some(details) = details { + egui::Panel::bottom("editor-fields").show(ui, |ui| { + details(ui); + ui.add_space(4.0); + }); + } if let Some(note) = editor.note { egui::Panel::top("editor-note").show(ui, |ui| { ui.add_space(2.0); diff --git a/src/view/tests.rs b/src/view/tests.rs index 4ea81d4..c374246 100644 --- a/src/view/tests.rs +++ b/src/view/tests.rs @@ -61,9 +61,8 @@ fn pass( .drop_without_applying_deltas(); } -/// Like `pass`, but with a panel on the right the way the app puts the -/// inspector there. Reports whether the widget filling that panel was -/// clicked. +/// Like `pass`, but with a panel beside the canvas. Reports whether the +/// widget filling that panel was clicked. fn pass_beside_a_panel( ctx: &egui::Context, view: &mut CanvasView, @@ -79,7 +78,7 @@ fn pass_beside_a_panel( }; let mut clicked = false; ctx.run_ui(input, |ui| { - egui::Panel::right("inspector") + egui::Panel::right("side") .default_size(300.0) .show(ui, |ui| { let size = ui.available_size(); @@ -276,10 +275,12 @@ fn the_editor_keeps_its_actions_in_a_strip_at_the_bottom() { assert_eq!(view.editing, None, "the button at {at:?} did not close it"); } -/// Every node takes an edit request; the ones with no text in them say -/// where their fields are instead of doing nothing. +/// A link node has nothing to type into on the card, so its address and +/// display text are edited in a window of their own. #[test] -fn an_edit_request_on_a_node_with_no_text_points_at_the_inspector() { +fn a_link_node_is_edited_in_a_window_of_its_own() { + let ctx = egui::Context::default(); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); let mut doc = Document::new(); doc.canvas = canvas_with_two_nodes(); doc.canvas.nodes.push(Node::new( @@ -295,15 +296,23 @@ fn an_edit_request_on_a_node_with_no_text_points_at_the_inspector() { )); let mut view = CanvasView::new(); - assert_eq!(view.begin_edit(&doc, "a"), EditTarget::Editor); - assert_eq!(view.editing.as_deref(), Some("a")); + view.edit_node("l"); + // The first pass opens the window and takes the keyboard; the second + // types into the field that has it, which is the URL. + pass(&ctx, &mut view, &mut doc, screen, vec![]); + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::Text("/spec".to_owned())], + ); - let EditTarget::Inspector(reason) = view.begin_edit(&doc, "l") else { - panic!("the editor took a link node, which has nothing to type into"); + let NodeKind::Link { url, .. } = &doc.canvas.node("l").unwrap().kind else { + panic!("the node stopped being a link"); }; - assert!(reason.contains("inspector"), "unhelpful reason: {reason:?}"); - // And the editor it could not take the node into is left as it was. - assert_eq!(view.editing.as_deref(), Some("a")); + assert_eq!(url, "https://example.com/spec"); + assert_eq!(doc.undo_label(), Some("Edit URL")); } /// The editor is a window with its own ways out, so the canvas no longer @@ -348,10 +357,11 @@ fn a_text_file_node_is_edited_in_the_editor_and_written_back() { let (dir, note, mut doc) = canvas_with_a_file_node(None); let mut view = CanvasView::new(); - assert!(view.edit_file_contents(&doc, "f")); + view.edit_node("f"); // The first pass opens the editor and takes the keyboard; the second // types into it. pass(&ctx, &mut view, &mut doc, screen, vec![]); + assert!(view.file_edit.is_some(), "the editor did not take the file"); pass( &ctx, &mut view, @@ -389,7 +399,7 @@ fn editing_a_section_leaves_the_rest_of_the_file_alone() { let (dir, note, mut doc) = canvas_with_a_file_node(Some("#Note")); let mut view = CanvasView::new(); - assert!(view.edit_file_contents(&doc, "f")); + view.edit_node("f"); pass(&ctx, &mut view, &mut doc, screen, vec![]); // The editor holds the section, not the whole file. let edit = view.file_edit.as_ref().expect("an open file editor"); @@ -417,8 +427,13 @@ fn editing_a_section_leaves_the_rest_of_the_file_alone() { std::fs::remove_dir_all(&dir).unwrap(); } +/// A file node whose file this editor will not change still opens: its own +/// fields are edited the same as any other node's. What it does not get is a +/// buffer on the file, which is what would let the file be typed over. #[test] fn only_a_whole_plain_text_file_is_editable() { + let ctx = egui::Context::default(); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); let (dir, note, mut doc) = canvas_with_a_file_node(None); let mut view = CanvasView::new(); let point_at = |doc: &mut Document, file: &str, subpath: Option<&str>| { @@ -438,15 +453,18 @@ fn only_a_whole_plain_text_file_is_editable() { ("note.md", Some("#Missing")), ] { point_at(&mut doc, file, subpath); + view.edit_node("f"); + pass(&ctx, &mut view, &mut doc, screen, vec![]); assert!( - !view.edit_file_contents(&doc, "f"), + view.file_edit.is_none(), "{file} {subpath:?} was offered for editing" ); - assert!(view.file_edit.is_none()); } point_at(&mut doc, "note.md", Some("#Other")); - assert!(view.edit_file_contents(&doc, "f")); + view.edit_node("f"); + pass(&ctx, &mut view, &mut doc, screen, vec![]); + assert!(view.file_edit.is_some(), "a section of a note was refused"); assert!(!view.files.is_edited(¬e), "looking at a file changed it"); std::fs::remove_dir_all(&dir).unwrap(); @@ -697,7 +715,7 @@ fn ctrl_and_a_two_finger_swipe_zooms() { } #[test] -fn a_click_in_the_inspector_stays_in_the_inspector() { +fn a_click_in_a_panel_beside_the_canvas_stays_in_the_panel() { let ctx = egui::Context::default(); let mut doc = Document::new(); doc.canvas = canvas_with_two_nodes(); @@ -743,6 +761,136 @@ fn canvas_with_two_nodes() -> Canvas { canvas } +/// An edge is painted, not laid out, so it has no widget to hang a menu on. +/// A right click on the line has to find it, take it over, and put its own +/// menu up in place of the one for bare canvas. +#[test] +fn a_right_click_on_an_edge_opens_the_edge_menu() { + let ctx = egui::Context::default(); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + doc.canvas + .edges + .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); + let mut view = CanvasView::new(); + view.selection.set_node("a"); + + // Halfway along the line from (100,50) to (300,50), clear of both cards. + right_click(&ctx, &mut view, &mut doc, screen, pos2(200.0, 50.0)); + pass(&ctx, &mut view, &mut doc, screen, vec![]); + + assert_eq!(view.selection.only_edge(), Some("e")); + assert!(view.selection.nodes.is_empty(), "the node stayed selected"); + assert!(open_menu_rect(&ctx).is_some(), "the edge menu never opened"); + + // And bare canvas still gets the menu of things to add. + right_click(&ctx, &mut view, &mut doc, screen, pos2(600.0, 600.0)); + assert_eq!(view.menu_edge, None); +} + +/// Everything about an edge that is not where its two ends are: the +/// side panel used to hold these, and now the edge's own editor does. +#[test] +fn an_edge_is_labelled_and_re_routed_in_its_editor() { + let ctx = egui::Context::default(); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + doc.canvas + .edges + .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); + let mut view = CanvasView::new(); + + view.edit_edge("e"); + // The first pass opens the window and hands the label field the keyboard. + pass(&ctx, &mut view, &mut doc, screen, vec![]); + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::Text("why".to_owned())], + ); + + assert_eq!(doc.canvas.edge("e").unwrap().label.as_deref(), Some("why")); + assert_eq!(doc.undo_label(), Some("Edit edge label")); +} + +/// Reversing an edge swaps the ends and everything that belongs to them. +#[test] +fn reversing_an_edge_swaps_its_ends() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut edge = Edge::new("e", "a", Side::Right, "b", Side::Left); + edge.from_end = Some(EndShape::Arrow); + edge.to_end = Some(EndShape::None); + doc.canvas.edges.push(edge); + let mut view = CanvasView::new(); + let mut output = ViewOutput::default(); + + view.apply_edge_action(&mut doc, "e", EdgeAction::Reverse, &mut output); + + let edge = doc.canvas.edge("e").unwrap(); + assert_eq!(edge.from_node, "b"); + assert_eq!(edge.to_node, "a"); + assert_eq!(edge.from_side, Some(Side::Left)); + assert_eq!(edge.from_end, Some(EndShape::None)); + assert!(output.status.is_some(), "nothing was said about it"); +} + +/// A group's background image is picked from a dialog the application owns, +/// so the menu can only ask for it. +#[test] +fn a_group_asks_the_application_for_its_background_image() { + let mut doc = Document::new(); + let mut view = CanvasView::new(); + let id = view.insert_node( + &mut doc, + NodeKind::Group { + label: Some("G".into()), + background: None, + background_style: None, + }, + pos2(0.0, 0.0), + ); + + let request = PickRequest::GroupBackground(id.clone()); + view.apply_pick(&mut doc, &request, "img/back.png"); + + let NodeKind::Group { background, .. } = &doc.canvas.node(&id).unwrap().kind else { + panic!("the node stopped being a group"); + }; + assert_eq!(background.as_deref(), Some("img/back.png")); + assert_eq!(doc.undo_label(), Some("Set background")); +} + +/// Lining nodes up moved out of the side panel and into the menu; the rule it +/// follows is still the box around everything selected. +#[test] +fn aligning_moves_nodes_to_a_shared_edge() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + doc.canvas.node_mut("b").unwrap().y = 200; + let mut view = CanvasView::new(); + view.select_all(&doc.canvas); + + view.align_selection(&mut doc, Alignment::Left); + assert_eq!(doc.canvas.node("b").unwrap().x, 0); + + view.align_selection(&mut doc, Alignment::Top); + assert_eq!(doc.canvas.node("b").unwrap().y, 0); + + // One node is already lined up with itself, and a step that changes + // nothing has no business on the undo stack. + let mut alone = Document::new(); + alone.canvas = canvas_with_two_nodes(); + let mut one = CanvasView::new(); + one.select_node("a"); + one.align_selection(&mut alone, Alignment::Right); + assert!(!alone.can_undo(), "a no-op made an undo step"); +} + #[test] fn selection_tracks_nodes_and_edges() { let mut selection = Selection::default(); diff --git a/tests/snapshots/editor-window.png b/tests/snapshots/editor-window.png index aeb5496..e8d4a9d 100644 Binary files a/tests/snapshots/editor-window.png and b/tests/snapshots/editor-window.png differ