1256 lines
43 KiB
Rust
1256 lines
43 KiB
Rust
//! The application shell: menus, toolbar, inspector, status bar, dialogs and
|
||
//! everything that touches the file system.
|
||
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use eframe::egui::{
|
||
self, Button, Context, Key, KeyboardShortcut, Modifiers, Pos2, RichText, Ui, ViewportCommand,
|
||
};
|
||
|
||
use crate::document::Document;
|
||
use crate::geometry::{bounds_of, node_rect};
|
||
use crate::inspector;
|
||
use crate::model::{Canvas, Node, NodeKind};
|
||
use crate::theme::Palette;
|
||
use crate::view::{CanvasView, Clipboard, InsertRequest};
|
||
|
||
/// How long a status message stays on screen.
|
||
const STATUS_SECONDS: f64 = 5.0;
|
||
|
||
/// An action waiting for the user to decide what to do about unsaved changes.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
enum Pending {
|
||
New,
|
||
Open,
|
||
OpenPath(PathBuf),
|
||
Quit,
|
||
}
|
||
|
||
pub struct App {
|
||
doc: Document,
|
||
view: CanvasView,
|
||
clipboard: Clipboard,
|
||
dark_mode: bool,
|
||
show_inspector: bool,
|
||
status: Option<(String, f64)>,
|
||
error: Option<String>,
|
||
pending: Option<Pending>,
|
||
quitting: bool,
|
||
show_about: bool,
|
||
show_shortcuts: bool,
|
||
title: String,
|
||
}
|
||
|
||
impl App {
|
||
pub fn new(cc: &eframe::CreationContext<'_>, open: Option<PathBuf>) -> Self {
|
||
egui_extras::install_image_loaders(&cc.egui_ctx);
|
||
let mut app = Self {
|
||
doc: Document::new(),
|
||
view: CanvasView::new(),
|
||
clipboard: Clipboard::default(),
|
||
dark_mode: true,
|
||
show_inspector: true,
|
||
status: None,
|
||
error: None,
|
||
pending: None,
|
||
quitting: false,
|
||
show_about: false,
|
||
show_shortcuts: false,
|
||
title: String::new(),
|
||
};
|
||
match open {
|
||
Some(path) => app.open_path(&path),
|
||
None => app.doc.canvas = welcome_canvas(),
|
||
}
|
||
app
|
||
}
|
||
|
||
fn palette(&self) -> Palette {
|
||
if self.dark_mode {
|
||
Palette::dark()
|
||
} else {
|
||
Palette::light()
|
||
}
|
||
}
|
||
|
||
fn set_status(&mut self, message: impl Into<String>, ctx: &Context) {
|
||
self.status = Some((message.into(), ctx.input(|i| i.time)));
|
||
}
|
||
|
||
// -- file actions -----------------------------------------------------
|
||
|
||
/// Whether anything would be lost by replacing the open canvas: changes to
|
||
/// the canvas itself, or to a file edited through one of its nodes.
|
||
fn has_unsaved_changes(&self) -> bool {
|
||
self.doc.is_dirty() || self.view.files.is_dirty()
|
||
}
|
||
|
||
/// Runs `pending` now, or asks about unsaved changes first.
|
||
fn guard(&mut self, pending: Pending) -> bool {
|
||
if self.has_unsaved_changes() {
|
||
self.pending = Some(pending);
|
||
false
|
||
} else {
|
||
true
|
||
}
|
||
}
|
||
|
||
fn new_document(&mut self, ctx: &Context) {
|
||
if !self.guard(Pending::New) {
|
||
return;
|
||
}
|
||
self.doc = Document::new();
|
||
self.reset_view();
|
||
self.set_status("New canvas", ctx);
|
||
}
|
||
|
||
fn open_dialog(&mut self, ctx: &Context) {
|
||
if !self.guard(Pending::Open) {
|
||
return;
|
||
}
|
||
let mut dialog = rfd::FileDialog::new()
|
||
.add_filter("JSON Canvas", &["canvas"])
|
||
.add_filter("All files", &["*"]);
|
||
if let Some(dir) = self.doc.base_dir() {
|
||
dialog = dialog.set_directory(dir);
|
||
}
|
||
if let Some(path) = dialog.pick_file() {
|
||
self.open_path(&path);
|
||
let name = self.doc.display_name();
|
||
self.set_status(format!("Opened {name}"), ctx);
|
||
}
|
||
}
|
||
|
||
fn open_path(&mut self, path: &Path) {
|
||
match Document::open(path) {
|
||
Ok(doc) => {
|
||
self.doc = doc;
|
||
self.reset_view();
|
||
}
|
||
Err(err) => self.error = Some(err),
|
||
}
|
||
}
|
||
|
||
fn reset_view(&mut self) {
|
||
self.view = CanvasView::new();
|
||
self.view.zoom_to_fit(&self.doc.canvas);
|
||
}
|
||
|
||
/// Saves the canvas, asking for a location when it has never been saved,
|
||
/// and writes every file edited through a file node back to its own file.
|
||
///
|
||
/// Returns `true` when everything made it to disk.
|
||
fn save(&mut self, ctx: &Context) -> bool {
|
||
// A canvas that has never been saved is only asked for a location when
|
||
// it has changes of its own: an edit made through a file node goes to
|
||
// that file and needs nothing from the canvas.
|
||
let canvas_pending = self.doc.is_dirty() || self.doc.path().is_some();
|
||
let mut ok = true;
|
||
let mut saved_canvas = false;
|
||
if canvas_pending {
|
||
match self.doc.save() {
|
||
Ok(true) => saved_canvas = true,
|
||
Ok(false) => {
|
||
ok = self.save_as(ctx);
|
||
saved_canvas = ok;
|
||
}
|
||
Err(err) => {
|
||
self.error = Some(err);
|
||
ok = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// The files second, so that one that will not open for writing cannot
|
||
// keep the canvas from being saved.
|
||
let (files, errors) = self.view.files.save_all();
|
||
if !errors.is_empty() {
|
||
self.error = Some(errors.join("\n\n"));
|
||
ok = false;
|
||
}
|
||
|
||
let mut saved: Vec<String> = Vec::new();
|
||
if saved_canvas {
|
||
saved.push(self.doc.display_name());
|
||
}
|
||
match files {
|
||
0 => {}
|
||
1 => saved.push("1 file".to_owned()),
|
||
n => saved.push(format!("{n} files")),
|
||
}
|
||
if !saved.is_empty() {
|
||
self.set_status(format!("Saved {}", saved.join(" and ")), ctx);
|
||
}
|
||
ok
|
||
}
|
||
|
||
fn save_as(&mut self, ctx: &Context) -> bool {
|
||
let mut dialog = rfd::FileDialog::new()
|
||
.add_filter("JSON Canvas", &["canvas"])
|
||
.set_file_name(self.doc.display_name());
|
||
if let Some(dir) = self.doc.base_dir() {
|
||
dialog = dialog.set_directory(dir);
|
||
}
|
||
let Some(mut path) = dialog.save_file() else {
|
||
return false;
|
||
};
|
||
if path.extension().is_none() {
|
||
path.set_extension("canvas");
|
||
}
|
||
match self.doc.save_as(&path) {
|
||
Ok(()) => {
|
||
let name = self.doc.display_name();
|
||
self.set_status(format!("Saved {name}"), ctx);
|
||
true
|
||
}
|
||
Err(err) => {
|
||
self.error = Some(err);
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Carries out the action that was waiting for the unsaved-changes answer.
|
||
fn run_pending(&mut self, ctx: &Context) {
|
||
let Some(pending) = self.pending.take() else {
|
||
return;
|
||
};
|
||
match pending {
|
||
Pending::New => {
|
||
self.doc = Document::new();
|
||
self.reset_view();
|
||
}
|
||
Pending::Open => self.open_dialog(ctx),
|
||
Pending::OpenPath(path) => self.open_path(&path),
|
||
Pending::Quit => {
|
||
self.quitting = true;
|
||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Opens a link or a file node target with the system default handler; a
|
||
/// `.canvas` file is opened in this editor instead.
|
||
fn open_target(&mut self, target: &str, ctx: &Context) {
|
||
if target.starts_with("http://")
|
||
|| target.starts_with("https://")
|
||
|| target.starts_with("mailto:")
|
||
{
|
||
if let Err(err) = open::that_detached(target) {
|
||
self.error = Some(format!("Could not open {target}: {err}"));
|
||
}
|
||
return;
|
||
}
|
||
|
||
let path = crate::nodes::resolve_path(self.doc.base_dir().as_deref(), target);
|
||
if path.extension().is_some_and(|e| e == "canvas") {
|
||
if self.guard(Pending::OpenPath(path.clone())) {
|
||
self.open_path(&path);
|
||
let name = self.doc.display_name();
|
||
self.set_status(format!("Opened {name}"), ctx);
|
||
}
|
||
return;
|
||
}
|
||
if !path.exists() {
|
||
self.error = Some(format!("{} does not exist.", path.display()));
|
||
return;
|
||
}
|
||
if let Err(err) = open::that_detached(&path) {
|
||
self.error = Some(format!("Could not open {}: {err}", path.display()));
|
||
}
|
||
}
|
||
|
||
// -- editing actions --------------------------------------------------
|
||
|
||
/// Adds the node the canvas context menu asked for, where it asked for it.
|
||
fn insert_requested(&mut self, kind: InsertRequest, at: Pos2, ctx: &Context) {
|
||
match kind {
|
||
InsertRequest::Text => self.insert_text_node(at, ctx),
|
||
InsertRequest::File => self.insert_file_node(at, ctx),
|
||
InsertRequest::Link => self.insert_link_node(at, ctx),
|
||
InsertRequest::Group => self.insert_group(at, ctx),
|
||
}
|
||
}
|
||
|
||
fn insert_text_node(&mut self, at: Pos2, ctx: &Context) {
|
||
let id = self.view.insert_node(
|
||
&mut self.doc,
|
||
NodeKind::Text {
|
||
text: String::new(),
|
||
},
|
||
at,
|
||
);
|
||
self.view.edit_node(&id);
|
||
self.set_status("Added a text node", ctx);
|
||
}
|
||
|
||
fn insert_file_node(&mut self, at: Pos2, ctx: &Context) {
|
||
let mut dialog = rfd::FileDialog::new();
|
||
if let Some(dir) = self.doc.base_dir() {
|
||
dialog = dialog.set_directory(dir);
|
||
}
|
||
let Some(path) = dialog.pick_file() else {
|
||
return;
|
||
};
|
||
let file = inspector::relative_to(self.doc.base_dir().as_deref(), &path);
|
||
self.view.insert_node(
|
||
&mut self.doc,
|
||
NodeKind::File {
|
||
file: file.clone(),
|
||
subpath: None,
|
||
},
|
||
at,
|
||
);
|
||
self.set_status(format!("Added {file}"), ctx);
|
||
if self.doc.path().is_none() {
|
||
self.set_status(
|
||
"Added a file node — save the canvas to store the path relative to it",
|
||
ctx,
|
||
);
|
||
}
|
||
}
|
||
|
||
fn insert_link_node(&mut self, at: Pos2, ctx: &Context) {
|
||
self.view.insert_node(
|
||
&mut self.doc,
|
||
NodeKind::Link {
|
||
url: "https://".to_owned(),
|
||
label: None,
|
||
},
|
||
at,
|
||
);
|
||
self.show_inspector = true;
|
||
self.set_status(
|
||
"Added a link node — set its URL and display text in the inspector",
|
||
ctx,
|
||
);
|
||
}
|
||
|
||
/// Adds the node a piece of loose text should become: a link node when the
|
||
/// text is a URL, a note otherwise.
|
||
fn insert_node_for_text(&mut self, text: &str, at: Pos2, ctx: &Context) {
|
||
let kind = NodeKind::from_text(text);
|
||
let link = matches!(kind, NodeKind::Link { .. });
|
||
self.view.insert_node(&mut self.doc, kind, at);
|
||
self.set_status(
|
||
if link {
|
||
"Added a link node"
|
||
} else {
|
||
"Added a text node"
|
||
},
|
||
ctx,
|
||
);
|
||
}
|
||
|
||
fn insert_group(&mut self, at: Pos2, ctx: &Context) {
|
||
self.view.insert_node(
|
||
&mut self.doc,
|
||
NodeKind::Group {
|
||
label: Some("Group".to_owned()),
|
||
background: None,
|
||
background_style: None,
|
||
},
|
||
at,
|
||
);
|
||
self.set_status("Added a group", ctx);
|
||
}
|
||
|
||
/// Wraps the selected nodes in a new group.
|
||
fn group_selection(&mut self, ctx: &Context) {
|
||
let rects = self
|
||
.view
|
||
.selection
|
||
.nodes
|
||
.iter()
|
||
.filter_map(|id| self.doc.canvas.node(id))
|
||
.map(node_rect);
|
||
let Some(bounds) = bounds_of(rects) else {
|
||
self.set_status("Select some nodes first", ctx);
|
||
return;
|
||
};
|
||
let bounds = bounds.expand(30.0);
|
||
let id = self.doc.canvas.fresh_id();
|
||
self.doc.change("Group selection");
|
||
let mut group = Node::new(
|
||
id.clone(),
|
||
NodeKind::Group {
|
||
label: Some("Group".to_owned()),
|
||
background: None,
|
||
background_style: None,
|
||
},
|
||
bounds.left().round() as i32,
|
||
(bounds.top() - 10.0).round() as i32,
|
||
bounds.width().round() as i32,
|
||
(bounds.height() + 10.0).round() as i32,
|
||
);
|
||
group.color = None;
|
||
self.doc.canvas.nodes.insert(0, group);
|
||
self.view.select_node(&id);
|
||
self.set_status("Grouped the selection", ctx);
|
||
}
|
||
|
||
fn duplicate_selection(&mut self, ctx: &Context) {
|
||
if let Some(clipboard) = self.view.copy_selection(&self.doc.canvas) {
|
||
let message = self
|
||
.view
|
||
.paste(&mut self.doc, &clipboard, egui::vec2(24.0, 24.0));
|
||
self.set_status(message.replace("Pasted", "Duplicated"), ctx);
|
||
}
|
||
}
|
||
|
||
fn copy_selection(&mut self, ctx: &Context) {
|
||
match self.view.copy_selection(&self.doc.canvas) {
|
||
Some(clipboard) => {
|
||
let count = clipboard.nodes.len();
|
||
// Also on the system clipboard, so the copy can be pasted into
|
||
// another window; `paste` recognises it again by its JSON.
|
||
ctx.copy_text(clipboard.to_json());
|
||
self.clipboard = clipboard;
|
||
self.set_status(format!("Copied {count} node(s)"), ctx);
|
||
}
|
||
None => self.set_status("Nothing to copy", ctx),
|
||
}
|
||
}
|
||
|
||
/// Pastes `text` from the system clipboard, falling back to the last copy
|
||
/// made in this window when there is none (the Edit menu has no way to read
|
||
/// the system clipboard).
|
||
///
|
||
/// Text that is a canvas pastes as nodes; any other text becomes a node of
|
||
/// its own — a link node for a URL, a note for anything else.
|
||
fn paste(&mut self, text: Option<&str>, ctx: &Context) {
|
||
if let Some(text) = text {
|
||
if let Some(clipboard) = Clipboard::from_json(text) {
|
||
self.paste_clipboard(&clipboard, ctx);
|
||
return;
|
||
}
|
||
if !text.trim().is_empty() {
|
||
let at = self.view.centre_in_canvas();
|
||
self.insert_node_for_text(text, at, ctx);
|
||
return;
|
||
}
|
||
}
|
||
if self.clipboard.is_empty() {
|
||
self.set_status("Nothing to paste", ctx);
|
||
return;
|
||
}
|
||
let clipboard = self.clipboard.clone();
|
||
self.paste_clipboard(&clipboard, ctx);
|
||
}
|
||
|
||
fn paste_clipboard(&mut self, clipboard: &Clipboard, ctx: &Context) {
|
||
let message = self
|
||
.view
|
||
.paste(&mut self.doc, clipboard, egui::vec2(24.0, 24.0));
|
||
self.set_status(message, ctx);
|
||
}
|
||
|
||
fn undo(&mut self, ctx: &Context) {
|
||
match self.doc.undo() {
|
||
Some(label) => self.set_status(format!("Undid: {label}"), ctx),
|
||
None => self.set_status("Nothing to undo", ctx),
|
||
}
|
||
}
|
||
|
||
fn redo(&mut self, ctx: &Context) {
|
||
match self.doc.redo() {
|
||
Some(label) => self.set_status(format!("Redid: {label}"), ctx),
|
||
None => self.set_status("Nothing to redo", ctx),
|
||
}
|
||
}
|
||
|
||
fn delete_selection(&mut self, ctx: &Context) {
|
||
if let Some(message) = self.view.delete_selection(&mut self.doc) {
|
||
self.set_status(message, ctx);
|
||
}
|
||
}
|
||
|
||
// -- keyboard ---------------------------------------------------------
|
||
|
||
fn handle_shortcuts(&mut self, ctx: &Context) {
|
||
use Key as K;
|
||
const CTRL: Modifiers = Modifiers::COMMAND;
|
||
let ctrl_shift = Modifiers::COMMAND.plus(Modifiers::SHIFT);
|
||
|
||
if consume(ctx, CTRL, K::N) {
|
||
self.new_document(ctx);
|
||
}
|
||
if consume(ctx, CTRL, K::O) {
|
||
self.open_dialog(ctx);
|
||
}
|
||
if consume(ctx, CTRL, K::S) {
|
||
self.save(ctx);
|
||
}
|
||
if consume(ctx, ctrl_shift, K::S) {
|
||
self.save_as(ctx);
|
||
}
|
||
if consume(ctx, CTRL, K::Z) {
|
||
self.undo(ctx);
|
||
}
|
||
if consume(ctx, ctrl_shift, K::Z) || consume(ctx, CTRL, K::Y) {
|
||
self.redo(ctx);
|
||
}
|
||
if consume(ctx, CTRL, K::A) {
|
||
self.view.select_all(&self.doc.canvas);
|
||
}
|
||
if consume(ctx, CTRL, K::D) {
|
||
self.duplicate_selection(ctx);
|
||
}
|
||
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);
|
||
}
|
||
if consume(ctx, CTRL, K::Num1) {
|
||
self.view.set_zoom(1.0);
|
||
}
|
||
if consume(ctx, CTRL, K::Plus) || consume(ctx, CTRL, K::Equals) {
|
||
self.view.set_zoom(self.view.zoom() * 1.2);
|
||
}
|
||
if consume(ctx, CTRL, K::Minus) {
|
||
self.view.set_zoom(self.view.zoom() / 1.2);
|
||
}
|
||
if consume(ctx, CTRL, K::Q) {
|
||
self.request_quit(ctx);
|
||
}
|
||
|
||
// Plain keys only when no text field is focused.
|
||
if ctx.egui_wants_keyboard_input() {
|
||
return;
|
||
}
|
||
|
||
// Ctrl+C and Ctrl+V never arrive as key presses: the windowing layer
|
||
// turns them into clipboard events before egui sees the key, and a
|
||
// focused text field (excluded just above) needs those events itself.
|
||
// Taking them out of the queue keeps anything drawn later from acting
|
||
// on the same paste a second time.
|
||
if take_event(ctx, |e| matches!(e, egui::Event::Copy).then_some(())).is_some() {
|
||
self.copy_selection(ctx);
|
||
}
|
||
if let Some(text) = take_event(ctx, |e| match e {
|
||
egui::Event::Paste(text) => Some(text.clone()),
|
||
_ => None,
|
||
}) {
|
||
self.paste(Some(&text), ctx);
|
||
}
|
||
if ctx.input_mut(|i| i.key_pressed(Key::Delete) || i.key_pressed(Key::Backspace)) {
|
||
self.delete_selection(ctx);
|
||
}
|
||
if ctx.input_mut(|i| i.key_pressed(Key::Escape)) {
|
||
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)
|
||
{
|
||
self.view.edit_node(&id);
|
||
}
|
||
if ctx.input_mut(|i| i.key_pressed(Key::F)) && !self.view.selection.is_empty() {
|
||
self.view.zoom_to_selection(&self.doc.canvas);
|
||
}
|
||
|
||
// Arrow keys nudge the selection.
|
||
let step = if ctx.input(|i| i.modifiers.shift) {
|
||
10.0
|
||
} else {
|
||
1.0
|
||
};
|
||
let mut delta = egui::Vec2::ZERO;
|
||
for (key, direction) in [
|
||
(Key::ArrowLeft, egui::vec2(-1.0, 0.0)),
|
||
(Key::ArrowRight, egui::vec2(1.0, 0.0)),
|
||
(Key::ArrowUp, egui::vec2(0.0, -1.0)),
|
||
(Key::ArrowDown, egui::vec2(0.0, 1.0)),
|
||
] {
|
||
if ctx.input(|i| i.key_pressed(key)) {
|
||
delta += direction * step;
|
||
}
|
||
}
|
||
if delta != egui::Vec2::ZERO {
|
||
self.view.nudge(&mut self.doc, delta);
|
||
self.doc.end_change();
|
||
}
|
||
}
|
||
|
||
fn request_quit(&mut self, ctx: &Context) {
|
||
if self.has_unsaved_changes() {
|
||
self.pending = Some(Pending::Quit);
|
||
} else {
|
||
self.quitting = true;
|
||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||
}
|
||
}
|
||
|
||
/// Files dropped on the window: canvases are opened, anything else becomes
|
||
/// a file node.
|
||
///
|
||
/// The new nodes land in the middle of the view because the drop carries no
|
||
/// position: X11 sends the pointer along with the drag, but winit drops
|
||
/// those coordinates and reports the file alone.
|
||
fn handle_dropped_files(&mut self, ctx: &Context) {
|
||
let dropped: Vec<PathBuf> = ctx.input(|i| {
|
||
i.raw
|
||
.dropped_files
|
||
.iter()
|
||
.map(|f| f.path().to_path_buf())
|
||
.collect()
|
||
});
|
||
if dropped.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let centre = self.view.centre_in_canvas();
|
||
let mut added: Vec<String> = Vec::new();
|
||
for path in dropped {
|
||
if path.extension().is_some_and(|e| e == "canvas") {
|
||
if self.guard(Pending::OpenPath(path.clone())) {
|
||
self.open_path(&path);
|
||
}
|
||
continue;
|
||
}
|
||
// 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);
|
||
// 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;
|
||
self.view.insert_node(
|
||
&mut self.doc,
|
||
NodeKind::File {
|
||
file: file.clone(),
|
||
subpath: None,
|
||
},
|
||
at,
|
||
);
|
||
added.push(file);
|
||
}
|
||
|
||
let mut message = match added.as_slice() {
|
||
[] => return,
|
||
[file] => format!("Added {file}"),
|
||
files => format!("Added {} file nodes", files.len()),
|
||
};
|
||
// An unsaved canvas has no directory for the paths to be relative to,
|
||
// so they went in absolute and will not travel with the file.
|
||
if self.doc.path().is_none() {
|
||
message.push_str(" — save the canvas to store the paths relative to it");
|
||
}
|
||
self.set_status(message, ctx);
|
||
}
|
||
|
||
// -- chrome -----------------------------------------------------------
|
||
|
||
fn menu_bar(&mut self, ui: &mut Ui, ctx: &Context) {
|
||
egui::MenuBar::new().ui(ui, |ui| {
|
||
ui.menu_button("File", |ui| {
|
||
if item(ui, "New", "Ctrl+N") {
|
||
self.new_document(ctx);
|
||
}
|
||
if item(ui, "Open…", "Ctrl+O") {
|
||
self.open_dialog(ctx);
|
||
}
|
||
ui.separator();
|
||
if item(ui, "Save", "Ctrl+S") {
|
||
self.save(ctx);
|
||
}
|
||
if item(ui, "Save as…", "Ctrl+Shift+S") {
|
||
self.save_as(ctx);
|
||
}
|
||
ui.separator();
|
||
if ui
|
||
.button("Reload files")
|
||
.on_hover_text("Read the files behind file nodes again")
|
||
.clicked()
|
||
{
|
||
// Files with edits that have not been saved yet are left
|
||
// alone: reading over them would throw the edits away.
|
||
let dropped = self.view.files.reload();
|
||
let kept = self.view.files.dirty_count();
|
||
self.set_status(
|
||
match kept {
|
||
0 => format!("Reloaded {dropped} file(s)"),
|
||
n => format!("Reloaded {dropped} file(s), kept {n} with edits"),
|
||
},
|
||
ctx,
|
||
);
|
||
ui.close();
|
||
}
|
||
ui.separator();
|
||
if item(ui, "Quit", "Ctrl+Q") {
|
||
self.request_quit(ctx);
|
||
}
|
||
});
|
||
|
||
ui.menu_button("Edit", |ui| {
|
||
let undo = self
|
||
.doc
|
||
.undo_label()
|
||
.map(|l| format!("Undo {l}"))
|
||
.unwrap_or_else(|| "Undo".to_owned());
|
||
if ui
|
||
.add_enabled(
|
||
self.doc.can_undo(),
|
||
Button::new(undo).shortcut_text("Ctrl+Z"),
|
||
)
|
||
.clicked()
|
||
{
|
||
self.undo(ctx);
|
||
ui.close();
|
||
}
|
||
let redo = self
|
||
.doc
|
||
.redo_label()
|
||
.map(|l| format!("Redo {l}"))
|
||
.unwrap_or_else(|| "Redo".to_owned());
|
||
if ui
|
||
.add_enabled(
|
||
self.doc.can_redo(),
|
||
Button::new(redo).shortcut_text("Ctrl+Shift+Z"),
|
||
)
|
||
.clicked()
|
||
{
|
||
self.redo(ctx);
|
||
ui.close();
|
||
}
|
||
ui.separator();
|
||
if item(ui, "Copy", "Ctrl+C") {
|
||
self.copy_selection(ctx);
|
||
}
|
||
if item(ui, "Paste", "Ctrl+V") {
|
||
self.paste(None, ctx);
|
||
}
|
||
if item(ui, "Duplicate", "Ctrl+D") {
|
||
self.duplicate_selection(ctx);
|
||
}
|
||
ui.separator();
|
||
if item(ui, "Select all", "Ctrl+A") {
|
||
self.view.select_all(&self.doc.canvas);
|
||
}
|
||
if item(ui, "Delete selection", "Del") {
|
||
self.delete_selection(ctx);
|
||
}
|
||
});
|
||
|
||
ui.menu_button("Insert", |ui| {
|
||
let at = self.view.centre_in_canvas();
|
||
if ui.button("Text node").clicked() {
|
||
self.insert_text_node(at, ctx);
|
||
ui.close();
|
||
}
|
||
if ui.button("File node…").clicked() {
|
||
self.insert_file_node(at, ctx);
|
||
ui.close();
|
||
}
|
||
if ui.button("Link node").clicked() {
|
||
self.insert_link_node(at, ctx);
|
||
ui.close();
|
||
}
|
||
if ui.button("Group").clicked() {
|
||
self.insert_group(at, ctx);
|
||
ui.close();
|
||
}
|
||
ui.separator();
|
||
if item(ui, "Group the selection", "Ctrl+G") {
|
||
self.group_selection(ctx);
|
||
}
|
||
});
|
||
|
||
ui.menu_button("View", |ui| {
|
||
if item(ui, "Zoom in", "Ctrl++") {
|
||
self.view.set_zoom(self.view.zoom() * 1.2);
|
||
}
|
||
if item(ui, "Zoom out", "Ctrl+-") {
|
||
self.view.set_zoom(self.view.zoom() / 1.2);
|
||
}
|
||
if item(ui, "Zoom to fit", "Ctrl+0") {
|
||
self.view.zoom_to_fit(&self.doc.canvas);
|
||
}
|
||
if item(ui, "Actual size", "Ctrl+1") {
|
||
self.view.set_zoom(1.0);
|
||
}
|
||
if item(ui, "Zoom to selection", "F") {
|
||
self.view.zoom_to_selection(&self.doc.canvas);
|
||
}
|
||
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.menu_button("Help", |ui| {
|
||
if ui.button("Keyboard shortcuts").clicked() {
|
||
self.show_shortcuts = true;
|
||
ui.close();
|
||
}
|
||
if ui.button("JSON Canvas specification").clicked() {
|
||
let _ = open::that_detached("https://jsoncanvas.org/spec/1.0/");
|
||
ui.close();
|
||
}
|
||
if ui.button("About").clicked() {
|
||
self.show_about = true;
|
||
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("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);
|
||
}
|
||
|
||
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) {
|
||
ui.horizontal(|ui| {
|
||
let dirty = if self.doc.is_dirty() { " •" } else { "" };
|
||
ui.label(
|
||
RichText::new(format!("{}{dirty}", self.doc.display_name()))
|
||
.size(12.0)
|
||
.color(palette.text),
|
||
);
|
||
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();
|
||
if edited > 0 {
|
||
ui.label(
|
||
RichText::new(match edited {
|
||
1 => "1 edited file".to_owned(),
|
||
n => format!("{n} edited files"),
|
||
})
|
||
.size(12.0)
|
||
.color(palette.accent),
|
||
)
|
||
.on_hover_text("Saving the canvas writes these back too");
|
||
ui.separator();
|
||
}
|
||
|
||
if let Some((message, at)) = &self.status {
|
||
if ctx.input(|i| i.time) - at < STATUS_SECONDS {
|
||
ui.label(RichText::new(message).size(12.0).color(palette.text_muted));
|
||
} else {
|
||
self.status = None;
|
||
}
|
||
}
|
||
|
||
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));
|
||
});
|
||
});
|
||
}
|
||
|
||
// -- dialogs ----------------------------------------------------------
|
||
|
||
fn dialogs(&mut self, ctx: &Context) {
|
||
if self.pending.is_some() {
|
||
let name = self.doc.display_name();
|
||
let edited = self.view.files.dirty_count();
|
||
egui::Modal::new(egui::Id::new("unsaved")).show(ctx, |ui| {
|
||
ui.set_width(360.0);
|
||
ui.heading("Unsaved changes");
|
||
ui.add_space(6.0);
|
||
let what = match (self.doc.is_dirty(), edited) {
|
||
(true, 0) => format!("“{name}” has changes that are not saved yet."),
|
||
(true, 1) => format!(
|
||
"“{name}” and one of its files have changes that are not saved yet."
|
||
),
|
||
(true, n) => format!(
|
||
"“{name}” and {n} of its files have changes that are not saved yet."
|
||
),
|
||
(false, 1) => {
|
||
"A file edited on this canvas has changes that are not saved yet."
|
||
.to_owned()
|
||
}
|
||
(false, n) => format!(
|
||
"{n} files edited on this canvas have changes that are not saved yet."
|
||
),
|
||
};
|
||
ui.label(what);
|
||
ui.add_space(12.0);
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Save and continue").clicked() {
|
||
if self.save(ctx) {
|
||
self.run_pending(ctx);
|
||
} else {
|
||
self.pending = None;
|
||
}
|
||
}
|
||
if ui.button("Discard changes").clicked() {
|
||
// The pending action replaces the document anyway.
|
||
self.run_pending(ctx);
|
||
}
|
||
if ui.button("Cancel").clicked() {
|
||
self.pending = None;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
if let Some(message) = self.error.clone() {
|
||
egui::Modal::new(egui::Id::new("error")).show(ctx, |ui| {
|
||
ui.set_width(420.0);
|
||
ui.heading("Something went wrong");
|
||
ui.add_space(6.0);
|
||
ui.label(message);
|
||
ui.add_space(12.0);
|
||
if ui.button("Close").clicked() {
|
||
self.error = None;
|
||
}
|
||
});
|
||
}
|
||
|
||
if self.show_about {
|
||
let mut open = true;
|
||
egui::Window::new("About")
|
||
.open(&mut open)
|
||
.collapsible(false)
|
||
.resizable(false)
|
||
.show(ctx, |ui| {
|
||
ui.label(RichText::new("JSON Canvas Editor").size(16.0).strong());
|
||
ui.add_space(4.0);
|
||
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
|
||
ui.add_space(8.0);
|
||
ui.label(
|
||
"A native editor for the JSON Canvas 1.0 file format. Everything is \
|
||
stored in plain .canvas files on this computer.",
|
||
);
|
||
ui.add_space(8.0);
|
||
ui.hyperlink_to("jsoncanvas.org", "https://jsoncanvas.org");
|
||
});
|
||
self.show_about = open;
|
||
}
|
||
|
||
if self.show_shortcuts {
|
||
let mut open = true;
|
||
egui::Window::new("Keyboard shortcuts")
|
||
.open(&mut open)
|
||
.collapsible(false)
|
||
.resizable(false)
|
||
.show(ctx, |ui| {
|
||
egui::Grid::new("shortcuts")
|
||
.num_columns(2)
|
||
.spacing([24.0, 6.0])
|
||
.show(ui, |ui| {
|
||
for (keys, what) in SHORTCUTS {
|
||
ui.label(RichText::new(*keys).monospace());
|
||
ui.label(*what);
|
||
ui.end_row();
|
||
}
|
||
});
|
||
});
|
||
self.show_shortcuts = open;
|
||
}
|
||
}
|
||
|
||
fn update_title(&mut self, ctx: &Context) {
|
||
let title = format!(
|
||
"{}{} — JSON Canvas Editor",
|
||
self.doc.display_name(),
|
||
if self.has_unsaved_changes() {
|
||
" •"
|
||
} else {
|
||
""
|
||
}
|
||
);
|
||
if title != self.title {
|
||
ctx.send_viewport_cmd(ViewportCommand::Title(title.clone()));
|
||
self.title = title;
|
||
}
|
||
}
|
||
}
|
||
|
||
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 {
|
||
egui::Visuals::dark()
|
||
} else {
|
||
egui::Visuals::light()
|
||
});
|
||
|
||
self.update_title(&ctx);
|
||
self.handle_shortcuts(&ctx);
|
||
self.handle_dropped_files(&ctx);
|
||
|
||
// Closing the window with unsaved changes asks first.
|
||
if ctx.input(|i| i.viewport().close_requested()) && !self.quitting {
|
||
if self.has_unsaved_changes() {
|
||
ctx.send_viewport_cmd(ViewportCommand::CancelClose);
|
||
self.pending = Some(Pending::Quit);
|
||
} else {
|
||
self.quitting = true;
|
||
}
|
||
}
|
||
|
||
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| {
|
||
ui.add_space(2.0);
|
||
self.status_bar(ui, &palette, &ctx);
|
||
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| {
|
||
let out = self.view.show(ui, &mut self.doc, &palette);
|
||
if let Some(message) = out.status {
|
||
self.set_status(message, &ctx);
|
||
}
|
||
if let Some(target) = out.open_target {
|
||
self.open_target(&target, &ctx);
|
||
}
|
||
if let Some((kind, at)) = out.insert {
|
||
self.insert_requested(kind, at, &ctx);
|
||
}
|
||
});
|
||
|
||
self.dialogs(&ctx);
|
||
}
|
||
}
|
||
|
||
const SHORTCUTS: &[(&str, &str)] = &[
|
||
("Ctrl+N / Ctrl+O", "New / open a canvas"),
|
||
("Ctrl+S / Ctrl+Shift+S", "Save / save as"),
|
||
("Ctrl+Z / Ctrl+Shift+Z", "Undo / redo"),
|
||
("Ctrl+C / Ctrl+D", "Copy / duplicate the selection"),
|
||
("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"),
|
||
("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"),
|
||
("Middle / right drag", "Pan the canvas"),
|
||
("Ctrl+wheel", "Zoom"),
|
||
("Ctrl+0 / Ctrl+1", "Zoom to fit / actual size"),
|
||
];
|
||
|
||
fn consume(ctx: &Context, modifiers: Modifiers, key: Key) -> bool {
|
||
ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(modifiers, key)))
|
||
}
|
||
|
||
/// Removes the first input event `wanted` accepts and returns what it made of it.
|
||
fn take_event<T>(ctx: &Context, wanted: impl Fn(&egui::Event) -> Option<T>) -> Option<T> {
|
||
ctx.input_mut(|i| {
|
||
let index = i.events.iter().position(|e| wanted(e).is_some())?;
|
||
wanted(&i.events.remove(index))
|
||
})
|
||
}
|
||
|
||
/// A menu entry with its shortcut, closing the menu when clicked.
|
||
fn item(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
|
||
let clicked = ui.add(Button::new(label).shortcut_text(shortcut)).clicked();
|
||
if clicked {
|
||
ui.close();
|
||
}
|
||
clicked
|
||
}
|
||
|
||
/// The canvas shown on a fresh start: a tiny tour of the format.
|
||
fn welcome_canvas() -> Canvas {
|
||
let mut canvas = Canvas::default();
|
||
let mut node = |id: &str, text: &str, x, y, w, h, color: Option<&str>| {
|
||
let mut node = Node::new(
|
||
id,
|
||
NodeKind::Text {
|
||
text: text.to_owned(),
|
||
},
|
||
x,
|
||
y,
|
||
w,
|
||
h,
|
||
);
|
||
node.color = color.and_then(crate::model::Color::parse);
|
||
canvas.nodes.push(node);
|
||
};
|
||
|
||
node(
|
||
"welcome",
|
||
"# JSON Canvas\n\nAn open file format for infinite canvases.\n\nEverything you see lives in a plain `.canvas` file.",
|
||
-260,
|
||
-220,
|
||
320,
|
||
170,
|
||
Some("6"),
|
||
);
|
||
node(
|
||
"nodes",
|
||
"## Nodes\n\n- **text** — Markdown, like this one\n- **file** — a document or image\n- **link** — a URL\n- **group** — a labelled box",
|
||
140,
|
||
-240,
|
||
280,
|
||
190,
|
||
Some("5"),
|
||
);
|
||
node(
|
||
"edges",
|
||
"## Edges\n\nDrag one of the dots on a node to connect it to another.",
|
||
140,
|
||
20,
|
||
280,
|
||
130,
|
||
Some("4"),
|
||
);
|
||
node(
|
||
"start",
|
||
"## Try it\n\n1. Double-click anywhere for a new note\n2. Drag the corners to resize\n3. Save with `Ctrl+S`",
|
||
-260,
|
||
0,
|
||
320,
|
||
150,
|
||
Some("3"),
|
||
);
|
||
|
||
canvas.edges.push(crate::model::Edge::new(
|
||
"e-welcome-nodes",
|
||
"welcome",
|
||
crate::model::Side::Right,
|
||
"nodes",
|
||
crate::model::Side::Left,
|
||
));
|
||
canvas.edges.push(crate::model::Edge::new(
|
||
"e-nodes-edges",
|
||
"nodes",
|
||
crate::model::Side::Bottom,
|
||
"edges",
|
||
crate::model::Side::Top,
|
||
));
|
||
canvas.edges.push(crate::model::Edge::new(
|
||
"e-welcome-start",
|
||
"welcome",
|
||
crate::model::Side::Bottom,
|
||
"start",
|
||
crate::model::Side::Top,
|
||
));
|
||
canvas
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn the_welcome_canvas_is_valid_and_connected() {
|
||
let canvas = welcome_canvas();
|
||
assert_eq!(canvas.nodes.len(), 4);
|
||
assert_eq!(canvas.edges.len(), 3);
|
||
let mut copy = canvas.clone();
|
||
assert_eq!(copy.drop_dangling_edges(), 0);
|
||
// And it round-trips through the file format.
|
||
assert_eq!(Canvas::from_json(&canvas.to_json()).unwrap(), canvas);
|
||
}
|
||
}
|