Files
jsoncanvas-desktop/src/app.rs
T
2026-09-10 15:28:37 +02:00

1256 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"),
("Wheel", "Zoom around the pointer"),
("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);
}
}