Major UI Rework

This commit is contained in:
2026-09-11 10:01:33 +02:00
parent 900132deeb
commit 5bd334c892
12 changed files with 1617 additions and 1160 deletions
Generated
+49
View File
@@ -24,6 +24,8 @@ version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
dependencies = [ dependencies = [
"enumn",
"serde",
"uuid", "uuid",
] ]
@@ -146,6 +148,7 @@ dependencies = [
"cfg-if", "cfg-if",
"getrandom 0.3.4", "getrandom 0.3.4",
"once_cell", "once_cell",
"serde",
"version_check", "version_check",
"zerocopy", "zerocopy",
] ]
@@ -449,6 +452,9 @@ name = "bitflags"
version = "2.13.1" version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "block2" name = "block2"
@@ -833,6 +839,7 @@ checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"emath", "emath",
"serde",
] ]
[[package]] [[package]]
@@ -861,6 +868,8 @@ dependencies = [
"pollster 1.0.1", "pollster 1.0.1",
"profiling", "profiling",
"raw-window-handle", "raw-window-handle",
"ron",
"serde",
"static_assertions", "static_assertions",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
@@ -885,6 +894,8 @@ dependencies = [
"log", "log",
"nohash-hasher", "nohash-hasher",
"profiling", "profiling",
"ron",
"serde",
"smallvec", "smallvec",
"unicode-segmentation", "unicode-segmentation",
"web-sys", "web-sys",
@@ -926,6 +937,7 @@ dependencies = [
"objc2-ui-kit 0.3.2", "objc2-ui-kit 0.3.2",
"profiling", "profiling",
"raw-window-handle", "raw-window-handle",
"serde",
"smithay-clipboard", "smithay-clipboard",
"web-time", "web-time",
"webbrowser", "webbrowser",
@@ -996,6 +1008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"serde",
] ]
[[package]] [[package]]
@@ -1045,6 +1058,17 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "epaint" name = "epaint"
version = "0.36.2" version = "0.36.2"
@@ -1063,6 +1087,7 @@ dependencies = [
"parking_lot", "parking_lot",
"profiling", "profiling",
"self_cell", "self_cell",
"serde",
"skrifa", "skrifa",
"smallvec", "smallvec",
"unicode-general-category", "unicode-general-category",
@@ -1184,6 +1209,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"serde",
] ]
[[package]] [[package]]
@@ -2908,6 +2934,20 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "1.1.0" version = "1.1.0"
@@ -3145,6 +3185,9 @@ name = "smallvec"
version = "1.16.0" version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "smithay-client-toolkit" name = "smithay-client-toolkit"
@@ -3479,6 +3522,12 @@ dependencies = [
"rustc-hash 2.1.3", "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]] [[package]]
name = "uds_windows" name = "uds_windows"
version = "1.2.1" version = "1.2.1"
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
eframe = "0.36.2" eframe = { version = "0.36.2", features = ["persistence"] }
egui_extras = { version = "0.36.2", features = ["image", "file"] } egui_extras = { version = "0.36.2", features = ["image", "file"] }
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] } image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] }
open = "5.4.3" open = "5.4.3"
+191 -134
View File
@@ -1,5 +1,5 @@
//! The application shell: menus, toolbar, inspector, status bar, dialogs and //! The application shell: menus, status bar, dialogs and everything that
//! everything that touches the file system. //! touches the file system.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -9,10 +9,11 @@ use eframe::egui::{
use crate::document::Document; use crate::document::Document;
use crate::geometry::{bounds_of, node_rect}; use crate::geometry::{bounds_of, node_rect};
use crate::inspector;
use crate::model::{Canvas, Node, NodeKind}; use crate::model::{Canvas, Node, NodeKind};
use crate::nodes::relative_to;
use crate::settings::Settings;
use crate::theme::Palette; 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. /// How long a status message stays on screen.
const STATUS_SECONDS: f64 = 5.0; const STATUS_SECONDS: f64 = 5.0;
@@ -36,8 +37,7 @@ pub struct App {
doc: Document, doc: Document,
view: CanvasView, view: CanvasView,
clipboard: Clipboard, clipboard: Clipboard,
dark_mode: bool, settings: Settings,
show_inspector: bool,
status: Option<(String, f64)>, status: Option<(String, f64)>,
error: Option<String>, error: Option<String>,
pending: Option<Pending>, pending: Option<Pending>,
@@ -51,12 +51,14 @@ pub struct App {
impl App { impl App {
pub fn new(cc: &eframe::CreationContext<'_>, open: Option<PathBuf>) -> Self { pub fn new(cc: &eframe::CreationContext<'_>, open: Option<PathBuf>) -> Self {
egui_extras::install_image_loaders(&cc.egui_ctx); 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 { let mut app = Self {
doc: Document::new(), doc: Document::new(),
view: CanvasView::new(), view,
clipboard: Clipboard::default(), clipboard: Clipboard::default(),
dark_mode: true, settings,
show_inspector: true,
status: None, status: None,
error: None, error: None,
pending: None, pending: None,
@@ -74,7 +76,7 @@ impl App {
} }
fn palette(&self) -> Palette { fn palette(&self) -> Palette {
if self.dark_mode { if self.settings.dark_mode {
Palette::dark() Palette::dark()
} else { } else {
Palette::light() Palette::light()
@@ -134,13 +136,37 @@ impl App {
Ok(doc) => { Ok(doc) => {
self.doc = doc; self.doc = doc;
self.reset_view(); 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) { fn reset_view(&mut self) {
let snap = self.view.snap_to_grid;
self.view = CanvasView::new(); self.view = CanvasView::new();
self.view.snap_to_grid = snap;
self.view.zoom_to_fit(&self.doc.canvas); self.view.zoom_to_fit(&self.doc.canvas);
} }
@@ -208,6 +234,7 @@ impl App {
match self.doc.save_as(&path) { match self.doc.save_as(&path) {
Ok(()) => { Ok(()) => {
let name = self.doc.display_name(); let name = self.doc.display_name();
self.settings.remember(&path);
self.set_status(format!("Saved {name}"), ctx); self.set_status(format!("Saved {name}"), ctx);
true true
} }
@@ -300,7 +327,7 @@ impl App {
let Some(path) = dialog.pick_file() else { let Some(path) = dialog.pick_file() else {
return; 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( self.view.insert_node(
&mut self.doc, &mut self.doc,
NodeKind::File { NodeKind::File {
@@ -319,7 +346,7 @@ impl App {
} }
fn insert_link_node(&mut self, at: Pos2, ctx: &Context) { fn insert_link_node(&mut self, at: Pos2, ctx: &Context) {
self.view.insert_node( let id = self.view.insert_node(
&mut self.doc, &mut self.doc,
NodeKind::Link { NodeKind::Link {
url: "https://".to_owned(), url: "https://".to_owned(),
@@ -327,11 +354,29 @@ impl App {
}, },
at, at,
); );
self.show_inspector = true; // Straight into the editor, because an empty link node shows nothing
self.set_status( // and there is no typing into the card itself.
"Added a link node — set its URL and display text in the inspector", self.view.edit_node(&id);
ctx, 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 /// 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) { if consume(ctx, CTRL, K::G) {
self.group_selection(ctx); self.group_selection(ctx);
} }
if consume(ctx, CTRL, K::I) {
self.show_inspector = !self.show_inspector;
}
if consume(ctx, CTRL, K::Num0) { if consume(ctx, CTRL, K::Num0) {
self.view.zoom_to_fit(&self.doc.canvas); self.view.zoom_to_fit(&self.doc.canvas);
} }
@@ -552,14 +594,12 @@ impl App {
self.view.stop_editing(); self.view.stop_editing();
self.view.selection.clear(); self.view.selection.clear();
} }
if ctx.input_mut(|i| i.key_pressed(Key::F2)) if ctx.input_mut(|i| i.key_pressed(Key::F2)) {
&& let Some(id) = self.view.selection.only_node().map(str::to_owned) if let Some(id) = self.view.selection.only_node().map(str::to_owned) {
&& let EditTarget::Inspector(reason) = self.view.begin_edit(&self.doc, &id) self.view.edit_node(&id);
{ } else if let Some(id) = self.view.selection.only_edge().map(str::to_owned) {
// Nothing to type into, so the fields that can be changed are put self.view.edit_edge(&id);
// where they are instead. }
self.show_inspector = true;
self.set_status(reason, ctx);
} }
if ctx.input_mut(|i| i.key_pressed(Key::F)) && !self.view.selection.is_empty() { if ctx.input_mut(|i| i.key_pressed(Key::F)) && !self.view.selection.is_empty() {
self.view.zoom_to_selection(&self.doc.canvas); 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 // A file below the canvas is stored relative to it, so the pair
// survives being moved or shared; anything else keeps its full path. // 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 // Step each further file down and right instead of stacking them all
// on the same spot. // on the same spot.
let at = centre + egui::vec2(24.0, 24.0) * added.len() as f32; 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") { if item(ui, "Open…", "Ctrl+O") {
self.open_dialog(ctx); self.open_dialog(ctx);
} }
ui.menu_button("Open recent", |ui| self.recent_menu(ui, ctx));
ui.separator(); ui.separator();
if item(ui, "Save", "Ctrl+S") { if item(ui, "Save", "Ctrl+S") {
self.save(ctx); self.save(ctx);
@@ -791,6 +832,23 @@ impl App {
if item(ui, "Delete selection", "Del") { if item(ui, "Delete selection", "Del") {
self.delete_selection(ctx); 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| { ui.menu_button("Insert", |ui| {
@@ -835,8 +893,7 @@ impl App {
} }
ui.separator(); ui.separator();
ui.checkbox(&mut self.view.snap_to_grid, "Snap to grid"); ui.checkbox(&mut self.view.snap_to_grid, "Snap to grid");
ui.checkbox(&mut self.show_inspector, "Inspector"); ui.checkbox(&mut self.settings.dark_mode, "Dark mode");
ui.checkbox(&mut self.dark_mode, "Dark mode");
}); });
ui.menu_button("Help", |ui| { ui.menu_button("Help", |ui| {
@@ -855,58 +912,55 @@ 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) { /// How many edges point at a node that is not on the canvas.
ui.horizontal(|ui| { 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()
}
/// 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 if ui
.button("New") .button("\u{26f6}")
.on_hover_text("New canvas (Ctrl+N)") .on_hover_text("Zoom to fit (Ctrl+0)")
.clicked() .clicked()
{ {
self.new_document(ctx); self.view.zoom_to_fit(&self.doc.canvas);
} }
if ui if ui.button("+").on_hover_text("Zoom in (Ctrl++)").clicked() {
.button("Open") self.view.set_zoom(self.view.zoom() * 1.2);
.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 if ui
.button(format!("{:.0}%", self.view.zoom() * 100.0)) .button(format!("{:.0}%", self.view.zoom() * 100.0))
@@ -915,35 +969,33 @@ impl App {
{ {
self.view.set_zoom(1.0); 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 if ui
.button("⛶") .button("\u{2212}")
.on_hover_text("Zoom to fit (Ctrl+0)") .on_hover_text("Zoom out (Ctrl+-)")
.clicked() .clicked()
{ {
self.view.zoom_to_fit(&self.doc.canvas); self.view.set_zoom(self.view.zoom() / 1.2);
} }
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;
}
});
});
} }
fn status_bar(&mut self, ui: &mut Ui, palette: &Palette, ctx: &Context) { 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| { ui.horizontal(|ui| {
let dirty = if self.doc.is_dirty() { " •" } else { "" }; let dirty = if self.doc.is_dirty() { " \u{2022}" } else { "" };
ui.label( ui.label(
RichText::new(format!("{}{dirty}", self.doc.display_name())) RichText::new(format!("{}{dirty}", self.doc.display_name()))
.size(12.0) .size(12.0)
@@ -951,6 +1003,26 @@ impl App {
); );
ui.separator(); 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 // Files edited through their nodes are written by Save along with
// the canvas, so say how many are waiting for it. // the canvas, so say how many are waiting for it.
let edited = self.view.files.dirty_count(); 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| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let selected = self.view.selection.len(); self.zoom_controls(ui);
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));
}); });
}); });
} }
@@ -1116,7 +1177,7 @@ impl eframe::App for App {
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();
let palette = self.palette(); let palette = self.palette();
ctx.set_visuals(if self.dark_mode { ctx.set_visuals(if self.settings.dark_mode {
egui::Visuals::dark() egui::Visuals::dark()
} else { } else {
egui::Visuals::light() egui::Visuals::light()
@@ -1140,8 +1201,6 @@ impl eframe::App for App {
egui::Panel::top("chrome").show(ui, |ui| { egui::Panel::top("chrome").show(ui, |ui| {
self.menu_bar(ui, &ctx); self.menu_bar(ui, &ctx);
ui.add_space(2.0); ui.add_space(2.0);
self.toolbar(ui, &ctx);
ui.add_space(2.0);
}); });
egui::Panel::bottom("status").show(ui, |ui| { egui::Panel::bottom("status").show(ui, |ui| {
@@ -1150,21 +1209,6 @@ impl eframe::App for App {
ui.add_space(2.0); 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() egui::CentralPanel::default()
.frame(egui::Frame::NONE) .frame(egui::Frame::NONE)
.show(ui, |ui| { .show(ui, |ui| {
@@ -1178,12 +1222,22 @@ impl eframe::App for App {
if let Some((kind, at)) = out.insert { if let Some((kind, at)) = out.insert {
self.insert_requested(kind, at, &ctx); self.insert_requested(kind, at, &ctx);
} }
if out.reveal_inspector { if let Some(request) = out.pick {
self.show_inspector = true; self.pick_file(&request, &ctx);
} }
}); });
self.dialogs(&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+V", "Paste nodes, or clipboard text as a node"),
("Ctrl+A", "Select everything"), ("Ctrl+A", "Select everything"),
("Ctrl+G", "Put the selection in a group"), ("Ctrl+G", "Put the selection in a group"),
("Ctrl+I", "Show or hide the inspector"),
("Delete", "Delete the selection"), ("Delete", "Delete the selection"),
("F2", "Edit the selected node"), ("F2", "Edit the selected node or edge"),
("F", "Zoom to the selection"), ("F", "Zoom to the selection"),
("Escape", "Stop editing, clear the selection"), ("Escape", "Stop editing, clear the selection"),
("Arrow keys", "Nudge (hold Shift for 10 units)"), ("Arrow keys", "Nudge (hold Shift for 10 units)"),
("Double-click", "New note, or edit what is under the cursor"), ("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"), ("Middle / right drag", "Pan the canvas"),
("Double-click wheel", "Back to 100%"), ("Double-click wheel", "Back to 100%"),
("Wheel", "Zoom around the pointer"), ("Wheel", "Zoom around the pointer"),
+105 -2
View File
@@ -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. /// The shell is real widgets too, found by the label on them.
#[test] #[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); let mut harness = harness_with_a_note("the only one", Renderer::None);
assert_eq!(harness.state().doc.canvas.nodes.len(), 1); 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(); harness.run();
assert_eq!(harness.state().doc.canvas.nodes.len(), 2); 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<String, String>);
impl eframe::Storage for FakeStorage {
fn get_string(&self, key: &str) -> Option<String> {
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 /// 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 /// along the bottom. Run `UPDATE_SNAPSHOTS=1 cargo test` after changing it
/// on purpose, and look at `tests/snapshots/*.diff.png` when it fails. /// on purpose, and look at `tests/snapshots/*.diff.png` when it fails.
-894
View File
@@ -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<String>,
pub open_target: Option<String>,
}
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<Side>) {
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<EndShape>, 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<Option<Color>> {
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::<String>() + chars.as_str(),
None => String::new(),
}
}
fn set_file(doc: &mut Document, id: &str, file: Option<String>, subpath: Option<String>) {
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<String>,
background: Option<String>,
style: Option<Option<BackgroundStyle>>,
) {
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<String> {
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<String> {
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(""), "");
}
}
+2 -1
View File
@@ -9,10 +9,10 @@ mod app;
mod document; mod document;
mod files; mod files;
mod geometry; mod geometry;
mod inspector;
mod markdown; mod markdown;
mod model; mod model;
mod nodes; mod nodes;
mod settings;
mod theme; mod theme;
mod view; mod view;
@@ -35,6 +35,7 @@ fn main() -> eframe::Result {
.with_inner_size([1280.0, 820.0]) .with_inner_size([1280.0, 820.0])
.with_min_inner_size([640.0, 420.0]) .with_min_inner_size([640.0, 420.0])
.with_app_id("jsoncanvas-desktop"), .with_app_id("jsoncanvas-desktop"),
persist_window: false,
..Default::default() ..Default::default()
}; };
+1 -1
View File
@@ -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 { pub fn display_name(&self) -> String {
match self { match self {
Self::Preset(n) => Self::PRESET_NAMES[(*n as usize) - 1].to_owned(), Self::Preset(n) => Self::PRESET_NAMES[(*n as usize) - 1].to_owned(),
+25
View File
@@ -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. /// A `file://` URI for egui's image loader.
/// ///
/// On Windows the loader wants the `file:///C:/dir/file.png` form: without the /// 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] #[test]
fn shows_the_host_of_a_link() { fn shows_the_host_of_a_link() {
assert_eq!( assert_eq!(
+113
View File
@@ -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<PathBuf>,
}
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());
}
}
+946 -91
View File
File diff suppressed because it is too large Load Diff
+168 -20
View File
@@ -61,9 +61,8 @@ fn pass(
.drop_without_applying_deltas(); .drop_without_applying_deltas();
} }
/// Like `pass`, but with a panel on the right the way the app puts the /// Like `pass`, but with a panel beside the canvas. Reports whether the
/// inspector there. Reports whether the widget filling that panel was /// widget filling that panel was clicked.
/// clicked.
fn pass_beside_a_panel( fn pass_beside_a_panel(
ctx: &egui::Context, ctx: &egui::Context,
view: &mut CanvasView, view: &mut CanvasView,
@@ -79,7 +78,7 @@ fn pass_beside_a_panel(
}; };
let mut clicked = false; let mut clicked = false;
ctx.run_ui(input, |ui| { ctx.run_ui(input, |ui| {
egui::Panel::right("inspector") egui::Panel::right("side")
.default_size(300.0) .default_size(300.0)
.show(ui, |ui| { .show(ui, |ui| {
let size = ui.available_size(); 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"); 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 /// A link node has nothing to type into on the card, so its address and
/// where their fields are instead of doing nothing. /// display text are edited in a window of their own.
#[test] #[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(); let mut doc = Document::new();
doc.canvas = canvas_with_two_nodes(); doc.canvas = canvas_with_two_nodes();
doc.canvas.nodes.push(Node::new( 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(); let mut view = CanvasView::new();
assert_eq!(view.begin_edit(&doc, "a"), EditTarget::Editor); view.edit_node("l");
assert_eq!(view.editing.as_deref(), Some("a")); // 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 { let NodeKind::Link { url, .. } = &doc.canvas.node("l").unwrap().kind else {
panic!("the editor took a link node, which has nothing to type into"); panic!("the node stopped being a link");
}; };
assert!(reason.contains("inspector"), "unhelpful reason: {reason:?}"); assert_eq!(url, "https://example.com/spec");
// And the editor it could not take the node into is left as it was. assert_eq!(doc.undo_label(), Some("Edit URL"));
assert_eq!(view.editing.as_deref(), Some("a"));
} }
/// The editor is a window with its own ways out, so the canvas no longer /// 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 (dir, note, mut doc) = canvas_with_a_file_node(None);
let mut view = CanvasView::new(); 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 // The first pass opens the editor and takes the keyboard; the second
// types into it. // types into it.
pass(&ctx, &mut view, &mut doc, screen, vec![]); pass(&ctx, &mut view, &mut doc, screen, vec![]);
assert!(view.file_edit.is_some(), "the editor did not take the file");
pass( pass(
&ctx, &ctx,
&mut view, &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 (dir, note, mut doc) = canvas_with_a_file_node(Some("#Note"));
let mut view = CanvasView::new(); let mut view = CanvasView::new();
assert!(view.edit_file_contents(&doc, "f")); view.edit_node("f");
pass(&ctx, &mut view, &mut doc, screen, vec![]); pass(&ctx, &mut view, &mut doc, screen, vec![]);
// The editor holds the section, not the whole file. // The editor holds the section, not the whole file.
let edit = view.file_edit.as_ref().expect("an open file editor"); 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(); 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] #[test]
fn only_a_whole_plain_text_file_is_editable() { 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 (dir, note, mut doc) = canvas_with_a_file_node(None);
let mut view = CanvasView::new(); let mut view = CanvasView::new();
let point_at = |doc: &mut Document, file: &str, subpath: Option<&str>| { 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")), ("note.md", Some("#Missing")),
] { ] {
point_at(&mut doc, file, subpath); point_at(&mut doc, file, subpath);
view.edit_node("f");
pass(&ctx, &mut view, &mut doc, screen, vec![]);
assert!( assert!(
!view.edit_file_contents(&doc, "f"), view.file_edit.is_none(),
"{file} {subpath:?} was offered for editing" "{file} {subpath:?} was offered for editing"
); );
assert!(view.file_edit.is_none());
} }
point_at(&mut doc, "note.md", Some("#Other")); 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(&note), "looking at a file changed it"); assert!(!view.files.is_edited(&note), "looking at a file changed it");
std::fs::remove_dir_all(&dir).unwrap(); std::fs::remove_dir_all(&dir).unwrap();
@@ -697,7 +715,7 @@ fn ctrl_and_a_two_finger_swipe_zooms() {
} }
#[test] #[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 ctx = egui::Context::default();
let mut doc = Document::new(); let mut doc = Document::new();
doc.canvas = canvas_with_two_nodes(); doc.canvas = canvas_with_two_nodes();
@@ -743,6 +761,136 @@ fn canvas_with_two_nodes() -> Canvas {
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] #[test]
fn selection_tracks_nodes_and_edges() { fn selection_tracks_nodes_and_edges() {
let mut selection = Selection::default(); let mut selection = Selection::default();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 60 KiB