Major UI Rework
This commit is contained in:
Generated
+49
@@ -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"
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+206
-149
@@ -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<String>,
|
||||
pending: Option<Pending>,
|
||||
@@ -51,12 +51,14 @@ pub struct App {
|
||||
impl App {
|
||||
pub fn new(cc: &eframe::CreationContext<'_>, open: Option<PathBuf>) -> 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"),
|
||||
|
||||
+105
-2
@@ -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<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
|
||||
/// along the bottom. Run `UPDATE_SNAPSHOTS=1 cargo test` after changing it
|
||||
/// on purpose, and look at `tests/snapshots/*.diff.png` when it fails.
|
||||
|
||||
@@ -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
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
@@ -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!(
|
||||
|
||||
+113
@@ -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());
|
||||
}
|
||||
}
|
||||
+947
-92
File diff suppressed because it is too large
Load Diff
+168
-20
@@ -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();
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 60 KiB |
Reference in New Issue
Block a user