From 5bd334c89202685a96cc19d71b9d4e420e439b08 Mon Sep 17 00:00:00 2001 From: luxick Date: Fri, 11 Sep 2026 10:01:33 +0200 Subject: [PATCH] Major UI Rework --- Cargo.lock | 49 ++ Cargo.toml | 2 +- src/app.rs | 355 +++++----- src/app/tests.rs | 107 ++- src/inspector.rs | 894 ------------------------- src/main.rs | 3 +- src/model.rs | 2 +- src/nodes.rs | 25 + src/settings.rs | 113 ++++ src/view.rs | 1039 ++++++++++++++++++++++++++--- src/view/tests.rs | 188 +++++- tests/snapshots/editor-window.png | Bin 86091 -> 61532 bytes 12 files changed, 1617 insertions(+), 1160 deletions(-) delete mode 100644 src/inspector.rs create mode 100644 src/settings.rs 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 aeb54960c528ad4ec578affddd07cfd4b1d52aa5..e8d4a9d0c7aeddbac1cf79473a9091a3b6a0b87c 100644 GIT binary patch literal 61532 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 literal 86091 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