//! 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, 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}; /// 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, pending: Option, quitting: bool, show_about: bool, show_shortcuts: bool, title: String, } impl App { pub fn new(cc: &eframe::CreationContext<'_>, open: Option) -> 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, ctx: &Context) { self.status = Some((message.into(), ctx.input(|i| i.time))); } // -- file actions ----------------------------------------------------- /// Runs `pending` now, or asks about unsaved changes first. fn guard(&mut self, pending: Pending) -> bool { if self.doc.is_dirty() { 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, asking for a location when the document has never been saved. /// Returns `true` when the file made it to disk. fn save(&mut self, ctx: &Context) -> bool { match self.doc.save() { Ok(true) => { let name = self.doc.display_name(); self.set_status(format!("Saved {name}"), ctx); true } Ok(false) => self.save_as(ctx), Err(err) => { self.error = Some(err); false } } } 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 -------------------------------------------------- fn insert_text_node(&mut self, ctx: &Context) { let at = self.view.centre_in_canvas(); 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, 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); let at = self.view.centre_in_canvas(); 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, ctx: &Context) { let at = self.view.centre_in_canvas(); 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, ); } fn insert_group(&mut self, ctx: &Context) { let at = self.view.centre_in_canvas(); 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(); self.clipboard = clipboard; self.set_status(format!("Copied {count} node(s)"), ctx); } None => self.set_status("Nothing to copy", ctx), } } fn paste(&mut self, ctx: &Context) { if self.clipboard.is_empty() { return; } let clipboard = self.clipboard.clone(); 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::C) { self.copy_selection(ctx); } if consume(ctx, CTRL, K::V) { self.paste(ctx); } 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; } 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.doc.is_dirty() { 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. fn handle_dropped_files(&mut self, ctx: &Context) { let dropped: Vec = ctx.input(|i| { i.raw .dropped_files .iter() .map(|f| f.path().to_path_buf()) .collect() }); for path in dropped { if path.extension().is_some_and(|e| e == "canvas") { if self.guard(Pending::OpenPath(path.clone())) { self.open_path(&path); } } else { let file = inspector::relative_to(self.doc.base_dir().as_deref(), &path); let at = self.view.centre_in_canvas(); self.view.insert_node( &mut self.doc, NodeKind::File { file: file.clone(), subpath: None, }, at, ); self.set_status(format!("Added {file}"), 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 file previews").clicked() { self.view.previews.clear(); self.set_status("Reloaded previews", 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(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| { if ui.button("Text node").clicked() { self.insert_text_node(ctx); ui.close(); } if ui.button("File node…").clicked() { self.insert_file_node(ctx); ui.close(); } if ui.button("Link node").clicked() { self.insert_link_node(ctx); ui.close(); } if ui.button("Group").clicked() { self.insert_group(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(); if ui.button("Note").on_hover_text("Add a text node").clicked() { self.insert_text_node(ctx); } if ui.button("File").on_hover_text("Add a file node").clicked() { self.insert_file_node(ctx); } if ui.button("Link").on_hover_text("Add a link node").clicked() { self.insert_link_node(ctx); } if ui.button("Group").on_hover_text("Add a group").clicked() { self.insert_group(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(); 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(); egui::Modal::new(egui::Id::new("unsaved")).show(ctx, |ui| { ui.set_width(360.0); ui.heading("Unsaved changes"); ui.add_space(6.0); ui.label(format!("“{name}” has changes that are not saved yet.")); 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.doc.is_dirty() { " •" } 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.doc.is_dirty() { 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); } }); 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+V / Ctrl+D", "Copy / paste / duplicate"), ("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 the one 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))) } /// 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); } }