diff --git a/src/app.rs b/src/app.rs index d322b50..01ccb44 100644 --- a/src/app.rs +++ b/src/app.rs @@ -79,9 +79,15 @@ impl App { // -- 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.doc.is_dirty() { + if self.has_unsaved_changes() { self.pending = Some(pending); false } else { @@ -130,21 +136,52 @@ impl App { 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. + /// 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 { - 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 + // 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 = 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 { @@ -540,7 +577,7 @@ impl App { } fn request_quit(&mut self, ctx: &Context) { - if self.doc.is_dirty() { + if self.has_unsaved_changes() { self.pending = Some(Pending::Quit); } else { self.quitting = true; @@ -624,9 +661,22 @@ impl App { self.save_as(ctx); } ui.separator(); - if ui.button("Reload file previews").clicked() { - self.view.previews.clear(); - self.set_status("Reloaded previews", ctx); + 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(); @@ -836,6 +886,22 @@ impl App { ); 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)); @@ -866,11 +932,28 @@ impl App { 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); - ui.label(format!("“{name}” has changes that are not saved yet.")); + 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() { @@ -951,7 +1034,11 @@ impl App { let title = format!( "{}{} — JSON Canvas Editor", self.doc.display_name(), - if self.doc.is_dirty() { " •" } else { "" } + if self.has_unsaved_changes() { + " •" + } else { + "" + } ); if title != self.title { ctx.send_viewport_cmd(ViewportCommand::Title(title.clone())); @@ -976,7 +1063,7 @@ impl eframe::App for App { // Closing the window with unsaved changes asks first. if ctx.input(|i| i.viewport().close_requested()) && !self.quitting { - if self.doc.is_dirty() { + if self.has_unsaved_changes() { ctx.send_viewport_cmd(ViewportCommand::CancelClose); self.pending = Some(Pending::Quit); } else { @@ -1045,7 +1132,7 @@ const SHORTCUTS: &[(&str, &str)] = &[ ("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"), + ("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"), diff --git a/src/files.rs b/src/files.rs new file mode 100644 index 0000000..1757644 --- /dev/null +++ b/src/files.rs @@ -0,0 +1,349 @@ +//! The text behind file nodes: read once, changed in place, and written back +//! when the canvas is saved. +//! +//! One entry per path rather than per node, so that two nodes pointing at the +//! same file always show the same text, and an edit made through one of them +//! appears in the other. + +use std::collections::HashMap; +use std::fs; +use std::ops::Range; +use std::path::{Path, PathBuf}; + +/// Only this much of a file is read. Beyond it the tail is not in memory, so +/// writing the buffer back would cut the file short — such a file is shown but +/// not edited. +pub const MAX_BYTES: usize = 64 * 1024; + +/// Why a file may be read here but not written. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Locked { + /// Only the head of the file was read. + TooLarge, + /// The bytes are not text, so what is on screen is a lossy rendering of + /// them and saving it would destroy the original. + NotText, +} + +impl Locked { + /// What to tell the user about a file that cannot be edited. + pub fn reason(self) -> &'static str { + match self { + Self::TooLarge => "Too large to edit here — only the first 64 KB are shown.", + Self::NotText => "Not a plain-text file, so it cannot be edited here.", + } + } +} + +/// A file that was read successfully. +pub struct Loaded { + /// The text as it now stands, including edits not yet on disk. + pub text: String, + /// Why the text may not be changed, or `None` when it may. + pub locked: Option, + /// Set once the text differs from the file on disk. + pub dirty: bool, +} + +/// The text of every file the canvas has looked at. +#[derive(Default)] +pub struct FileStore { + files: HashMap>, +} + +impl FileStore { + /// The text of `path`, reading it the first time it is asked for. + pub fn entry(&mut self, path: &Path) -> &Result { + self.files + .entry(path.to_path_buf()) + .or_insert_with(|| read(path)) + } + + /// Replaces the text of a file that has already been read, marking it as + /// changed. Does nothing for a file that has not, which is every file no + /// editor has been opened on. + pub fn set(&mut self, path: &Path, text: String) { + if let Some(Ok(loaded)) = self.files.get_mut(path) + && loaded.text != text + { + loaded.text = text; + loaded.dirty = true; + } + } + + /// Whether `path` carries edits that are not on disk. Never reads. + pub fn is_edited(&self, path: &Path) -> bool { + matches!(self.files.get(path), Some(Ok(loaded)) if loaded.dirty) + } + + pub fn dirty_count(&self) -> usize { + self.files + .values() + .filter(|entry| matches!(entry, Ok(loaded) if loaded.dirty)) + .count() + } + + pub fn is_dirty(&self) -> bool { + self.dirty_count() > 0 + } + + /// Forgets what was read, so the next look at a file reads it again. + /// + /// Files with unsaved edits are kept: throwing those away is the one thing + /// a refresh must not do. Returns how many were let go. + pub fn reload(&mut self) -> usize { + let before = self.files.len(); + self.files + .retain(|_, entry| matches!(entry, Ok(loaded) if loaded.dirty)); + before - self.files.len() + } + + /// Writes every changed file back to disk. + /// + /// Returns how many were written and what went wrong with the rest; a file + /// that could not be written keeps its edits, so saving again retries it. + pub fn save_all(&mut self) -> (usize, Vec) { + let mut written = 0; + let mut errors = Vec::new(); + // A stable order, so repeated failures read the same way every time. + let mut paths: Vec = self.files.keys().cloned().collect(); + paths.sort(); + for path in paths { + let Some(Ok(loaded)) = self.files.get_mut(&path) else { + continue; + }; + if !loaded.dirty { + continue; + } + match fs::write(&path, &loaded.text) { + Ok(()) => { + loaded.dirty = false; + written += 1; + } + Err(err) => errors.push(format!("Could not write {}: {err}", path.display())), + } + } + (written, errors) + } +} + +fn read(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|err| format!("{err}"))?; + let too_large = bytes.len() > MAX_BYTES; + let head = &bytes[..bytes.len().min(MAX_BYTES)]; + // A file cut off mid-character is not proof that the file is binary, so + // its size is the reason it is locked and the encoding is not looked at. + let (text, locked) = match std::str::from_utf8(head) { + _ if too_large => ( + String::from_utf8_lossy(head).into_owned(), + Some(Locked::TooLarge), + ), + Ok(text) => (text.to_owned(), None), + Err(_) => ( + String::from_utf8_lossy(head).into_owned(), + Some(Locked::NotText), + ), + }; + Ok(Loaded { + text, + locked, + dirty: false, + }) +} + +// --------------------------------------------------------------------------- +// Sections +// --------------------------------------------------------------------------- + +/// The lines of `text`, each keeping the line ending it came with. +/// +/// `str::lines` drops the break, and with it the difference between a file +/// written with CRLF and one written with LF. Splitting inclusively keeps a +/// file's own line endings intact through an edit to one section of it. +fn raw_lines(text: &str) -> Vec<&str> { + text.split_inclusive('\n').collect() +} + +/// The level and title of a Markdown heading line, or `None` for any other +/// line. +fn heading_of(line: &str) -> Option<(usize, &str)> { + let line = line.trim_end_matches(['\n', '\r']); + let hashes = line.chars().take_while(|c| *c == '#').count(); + ((1..=6).contains(&hashes) && line[hashes..].starts_with(' ')) + .then(|| (hashes, line[hashes..].trim())) +} + +/// The lines the section a file node's `subpath` points at covers: the heading +/// itself and everything under it, up to the next heading of the same level or +/// a higher one. +/// +/// `None` when `subpath` names no heading of `text`, and for a `subpath` that +/// is not a heading reference — the format allows other forms, and nothing +/// else is understood here. +pub fn section_range(text: &str, subpath: Option<&str>) -> Option> { + let wanted = subpath?.strip_prefix('#')?.trim().to_ascii_lowercase(); + let lines = raw_lines(text); + let mut found: Option<(usize, usize)> = None; + let mut end = lines.len(); + for (index, line) in lines.iter().enumerate() { + let heading = heading_of(line); + match found { + None => { + if let Some((level, title)) = heading + && title.to_ascii_lowercase() == wanted + { + found = Some((index, level)); + } + } + Some((_, level)) => { + if heading.is_some_and(|(hashes, _)| hashes <= level) { + end = index; + break; + } + } + } + } + let (start, _) = found?; + // The blank lines before the next heading separate the two sections rather + // than belonging to this one, so an edit here cannot swallow them. + while end > start + 1 && lines[end - 1].trim().is_empty() { + end -= 1; + } + Some(start..end) +} + +/// The lines `range` covers, as one piece of text. +pub fn slice_lines(text: &str, range: Range) -> String { + raw_lines(text)[range].concat() +} + +/// Puts `replacement` where the lines `range` covers were. +/// +/// Returns the whole text and the lines the replacement now occupies, which is +/// where the next edit to the same section goes. +pub fn splice_lines(text: &str, range: Range, replacement: &str) -> (String, Range) { + let lines = raw_lines(text); + let head = lines[..range.start].concat(); + let tail = lines[range.end..].concat(); + let mut middle = replacement.to_owned(); + // Without a break of its own the last line of the edit would run into the + // first line of the rest of the file. + if !tail.is_empty() && !middle.is_empty() && !middle.ends_with('\n') { + middle.push('\n'); + } + let count = raw_lines(&middle).len(); + ( + format!("{head}{middle}{tail}"), + range.start..range.start + count, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DOC: &str = "# One\nalpha\n\n## Two\nbeta\n\n# Three\ngamma"; + + #[test] + fn a_section_runs_to_the_next_heading_of_its_level() { + assert_eq!(section_range(DOC, Some("#Two")), Some(3..5)); + assert_eq!(slice_lines(DOC, 3..5), "## Two\nbeta\n"); + // A top-level heading takes the sections nested under it with it. + assert_eq!(section_range(DOC, Some("#One")), Some(0..5)); + assert_eq!(section_range(DOC, Some("#Three")), Some(6..8)); + } + + #[test] + fn a_subpath_that_names_nothing_has_no_range() { + assert_eq!(section_range(DOC, Some("#Missing")), None); + assert_eq!(section_range(DOC, Some("not-a-heading")), None); + assert_eq!(section_range(DOC, None), None); + } + + #[test] + fn splicing_puts_an_edited_section_back() { + let (text, moved) = splice_lines(DOC, 3..5, "## Two\nbeta\ngamma\n"); + assert_eq!( + text, + "# One\nalpha\n\n## Two\nbeta\ngamma\n\n# Three\ngamma" + ); + assert_eq!(moved, 3..6); + // And the range it reports is where that section now is. + assert_eq!(slice_lines(&text, moved), "## Two\nbeta\ngamma\n"); + } + + #[test] + fn splicing_keeps_the_rest_of_the_file_on_its_own_line() { + // The editor's buffer has no trailing break of its own. + let (text, _) = splice_lines(DOC, 3..5, "## Two\nbeta"); + assert_eq!(text, DOC); + // Clearing a section removes it rather than leaving a blank line. + let (text, moved) = splice_lines(DOC, 3..5, ""); + assert_eq!(text, "# One\nalpha\n\n\n# Three\ngamma"); + assert_eq!(moved, 3..3); + } + + #[test] + fn crlf_files_keep_their_line_endings() { + let text = "# One\r\nalpha\r\n\r\n# Two\r\nbeta\r\n"; + let range = section_range(text, Some("#One")).unwrap(); + assert_eq!(slice_lines(text, range.clone()), "# One\r\nalpha\r\n"); + let (spliced, _) = splice_lines(text, range, "# One\r\nalpha\r\ngamma\r\n"); + assert_eq!(spliced, "# One\r\nalpha\r\ngamma\r\n\r\n# Two\r\nbeta\r\n"); + } + + #[test] + fn a_file_is_read_once_and_written_back_when_changed() -> Result<(), Box> + { + let dir = + std::env::temp_dir().join(format!("jsoncanvas-files-{}", crate::model::random_id())); + fs::create_dir_all(&dir)?; + let path = dir.join("note.md"); + fs::write(&path, "# Note\n")?; + + let mut store = FileStore::default(); + assert_eq!(store.entry(&path).as_ref().unwrap().text, "# Note\n"); + assert!(!store.is_dirty()); + + store.set(&path, "# Note\nbody\n".to_owned()); + assert!(store.is_edited(&path)); + // A refresh must not throw an unsaved edit away. + assert_eq!(store.reload(), 0); + assert!(store.is_edited(&path)); + + assert_eq!(store.save_all(), (1, Vec::new())); + assert_eq!(fs::read_to_string(&path)?, "# Note\nbody\n"); + assert!(!store.is_dirty()); + assert_eq!(store.reload(), 1); + + fs::remove_dir_all(&dir)?; + Ok(()) + } + + #[test] + fn a_file_that_is_not_text_is_read_but_locked() -> Result<(), Box> { + let dir = + std::env::temp_dir().join(format!("jsoncanvas-files-{}", crate::model::random_id())); + fs::create_dir_all(&dir)?; + let path = dir.join("blob.txt"); + fs::write(&path, [0xff, 0xfe, 0x00])?; + let mut store = FileStore::default(); + assert_eq!( + store.entry(&path).as_ref().unwrap().locked, + Some(Locked::NotText) + ); + + let big = dir.join("big.txt"); + fs::write(&big, "x".repeat(MAX_BYTES + 1))?; + assert_eq!( + store.entry(&big).as_ref().unwrap().locked, + Some(Locked::TooLarge) + ); + + // A file that is not there reports why. + assert!(store.entry(&dir.join("gone.txt")).is_err()); + + fs::remove_dir_all(&dir)?; + Ok(()) + } +} diff --git a/src/inspector.rs b/src/inspector.rs index af03ef2..79eb3ab 100644 --- a/src/inspector.rs +++ b/src/inspector.rs @@ -101,6 +101,19 @@ fn node_section( { out.open_target = Some(file.clone()); } + // Editing happens on the canvas, in the node itself; 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 the node on the canvas") + .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")); diff --git a/src/main.rs b/src/main.rs index 70174b7..ba9931c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod app; mod document; +mod files; mod geometry; mod inspector; mod markdown; diff --git a/src/nodes.rs b/src/nodes.rs index bbb4a18..115e311 100644 --- a/src/nodes.rs +++ b/src/nodes.rs @@ -1,18 +1,15 @@ //! Rendering the *body* of a node: markdown for text nodes, previews for file //! nodes, the target of link nodes. -use std::collections::HashMap; use std::path::{Path, PathBuf}; use eframe::egui::{self, Align, Layout, RichText, Ui}; +use crate::files::{self, FileStore}; use crate::markdown; use crate::model::{Node, NodeKind, display_text, file_name}; use crate::theme::Palette; -/// Files larger than this are not previewed as text. -const MAX_PREVIEW_BYTES: usize = 64 * 1024; - /// What a file node points at. #[derive(Debug, Clone, PartialEq, Eq)] pub enum FileKind { @@ -34,35 +31,6 @@ pub fn file_kind(path: &str) -> FileKind { } } -/// Cache for the text previews of file nodes, so the disk is not hit per frame. -#[derive(Default)] -pub struct PreviewCache { - text: HashMap>, -} - -impl PreviewCache { - pub fn clear(&mut self) { - self.text.clear(); - } - - fn text_preview(&mut self, path: &Path) -> &Result { - self.text - .entry(path.to_path_buf()) - .or_insert_with(|| match std::fs::read(path) { - Ok(bytes) => { - let truncated = bytes.len() > MAX_PREVIEW_BYTES; - let head = &bytes[..bytes.len().min(MAX_PREVIEW_BYTES)]; - let mut text = String::from_utf8_lossy(head).into_owned(); - if truncated { - text.push_str("\n\n…"); - } - Ok(text) - } - Err(err) => Err(format!("{err}")), - }) - } -} - /// Resolves a node path against the folder the canvas lives in. pub fn resolve_path(base_dir: Option<&Path>, raw: &str) -> PathBuf { let path = Path::new(raw); @@ -90,7 +58,7 @@ pub fn file_uri(path: &Path) -> String { pub struct BodyContext<'a> { pub palette: &'a Palette, pub base_dir: Option<&'a Path>, - pub cache: &'a mut PreviewCache, + pub files: &'a mut FileStore, /// Current canvas scale, used to skip detail when zoomed far out. pub scale: f32, } @@ -143,6 +111,7 @@ fn render_file( let palette = ctx.palette; let path = resolve_path(ctx.base_dir, file); let kind = file_kind(file); + let edited = ctx.files.is_edited(&path); let mut activated = None; // Header: the file name, plus the subpath when the node points inside a file. @@ -169,6 +138,11 @@ fn render_file( if let Some(subpath) = subpath { ui.label(RichText::new(subpath).size(12.0).color(palette.text_muted)); } + // The same mark the title bar puts on a canvas with changes to save. + if edited { + ui.label(RichText::new("\u{2022}").size(13.0).color(palette.accent)) + .on_hover_text("Edited here \u{2014} save the canvas to write it to the file"); + } }); if ctx.scale < 0.4 { @@ -196,13 +170,23 @@ fn render_file( .corner_radius(4.0), ); } - FileKind::Text => match ctx.cache.text_preview(&path) { - Ok(text) => { - let excerpt = excerpt_for(text, subpath); + FileKind::Text => match ctx.files.entry(&path) { + Ok(loaded) => { + let excerpt = excerpt_for(&loaded.text, subpath); + let note = loaded.locked.map(files::Locked::reason); let blocks = markdown::parse(&excerpt); if let Some(link) = markdown::render(ui, &blocks, palette, 13.0) { activated = Some(link); } + if let Some(note) = note { + ui.add_space(4.0); + ui.label( + RichText::new(note) + .size(11.0) + .italics() + .color(palette.text_muted), + ); + } } Err(err) => { ui.label( @@ -230,28 +214,9 @@ fn excerpt_for(text: &str, subpath: Option<&str>) -> String { let Some(heading) = subpath.and_then(|s| s.strip_prefix('#')) else { return text.to_owned(); }; - let heading = heading.trim().to_ascii_lowercase(); - let mut out = Vec::new(); - let mut level = 0usize; - for line in text.lines() { - let hashes = line.chars().take_while(|c| *c == '#').count(); - let is_heading = (1..=6).contains(&hashes) && line[hashes..].starts_with(' '); - if level == 0 { - if is_heading && line[hashes..].trim().to_ascii_lowercase() == heading { - level = hashes; - out.push(line.to_owned()); - } - } else { - if is_heading && hashes <= level { - break; - } - out.push(line.to_owned()); - } - } - if out.is_empty() { - format!("*Section “{heading}” not found*") - } else { - out.join("\n") + match files::section_range(text, subpath) { + Some(range) => files::slice_lines(text, range), + None => format!("*Section \u{201c}{}\u{201d} not found*", heading.trim()), } } diff --git a/src/view.rs b/src/view.rs index 408cb68..aad5504 100644 --- a/src/view.rs +++ b/src/view.rs @@ -2,6 +2,8 @@ //! manipulation gesture (select, move, resize, connect, edit). use std::collections::HashMap; +use std::ops::Range; +use std::path::PathBuf; use eframe::egui::{ self, Align, Atom, Button, Color32, CornerRadius, CursorIcon, Id, LayerId, Layout, @@ -11,12 +13,13 @@ use eframe::egui::{ use eframe::emath::TSTransform; use crate::document::Document; +use crate::files::{self, FileStore}; use crate::geometry::{ anchor, auto_sides, bezier_point, bezier_tangent, bounds_of, curve_between, distance_to_curve, edge_curve, node_rect, }; use crate::model::{Canvas, Color, Edge, EndShape, Extra, MIN_NODE_SIZE, Node, NodeKind, Side}; -use crate::nodes::{self, BodyContext, PreviewCache}; +use crate::nodes::{self, BodyContext, FileKind}; use crate::theme::{self, Palette}; /// Zoom limits. Beyond 3x text starts to look soft, which is the usual @@ -257,6 +260,8 @@ pub struct CanvasView { gesture: Gesture, /// Node whose text is being edited in place. editing: Option, + /// The text an open file-node editor is working on, while one is open. + file_edit: Option, /// Set for one frame to move keyboard focus into a freshly opened editor. focus_editor: bool, hovered_edge: Option, @@ -264,7 +269,8 @@ pub struct CanvasView { /// Node whose connect handles are on screen, held still during a drag. connect_target: Option, pub snap_to_grid: bool, - pub previews: PreviewCache, + /// The text of every file the canvas shows, edits included. + pub files: FileStore, /// How far each node's contents are scrolled, and how far they can go. body_scroll: HashMap, /// Where the open context menu was summoned, in canvas coordinates. The @@ -278,6 +284,23 @@ pub struct CanvasView { pending_fit: Option, } +/// The text of the file behind the file node being edited. +/// +/// The buffer is held here rather than read out of the store every frame, and +/// what the user types is written back into the store on every change, so the +/// nodes around the editor show the edit as it is made. +struct FileEdit { + /// The node the editor belongs to. + id: String, + /// The file it writes to. + path: PathBuf, + /// For a node pointing at one section of the file, the lines that section + /// covers right now; `None` when the editor holds the whole file. + section: Option>, + /// What is in the editor. + buffer: String, +} + /// The scroll position of one node's contents. #[derive(Debug, Clone, Copy, Default)] struct BodyScroll { @@ -310,12 +333,13 @@ impl CanvasView { selection: Selection::default(), gesture: Gesture::None, editing: None, + file_edit: None, focus_editor: false, hovered_edge: None, hovered_node: None, connect_target: None, snap_to_grid: false, - previews: PreviewCache::default(), + files: FileStore::default(), body_scroll: HashMap::new(), menu_at: None, viewport: None, @@ -327,8 +351,11 @@ impl CanvasView { self.to_global.scaling } + /// Closes any open editor. Edits made to a file are kept: they are already + /// in [`CanvasView::files`], waiting to be saved. pub fn stop_editing(&mut self) { self.editing = None; + self.file_edit = None; } /// Starts in-place editing of a text node or group label. @@ -337,6 +364,16 @@ impl CanvasView { self.focus_editor = true; } + /// Starts in-place editing of the file behind a file node, reporting + /// `false` for a file this editor will not change. + pub fn edit_file_contents(&mut self, doc: &Document, id: &str) -> bool { + let can = self.can_edit_file(doc, id); + if can { + self.edit_node(id); + } + can + } + pub fn centre_in_canvas(&self) -> Pos2 { self.to_global.inverse() * self.viewport().center() } @@ -462,7 +499,7 @@ impl CanvasView { .retain(|e| !self.selection.edges.contains(&e.id)); let edges = edges_before - doc.canvas.edges.len(); self.selection.clear(); - self.editing = None; + self.stop_editing(); Some(format!( "Deleted {}", count_phrase(&[(nodes, "node"), (edges, "edge")]) @@ -613,7 +650,7 @@ impl CanvasView { .as_ref() .is_some_and(|id| doc.canvas.node(id).is_none()) { - self.editing = None; + self.stop_editing(); } let outer = ui.available_rect_before_wrap(); @@ -885,9 +922,9 @@ impl CanvasView { if scale >= 0.18 && !is_group { let inner = rect.shrink(NODE_PADDING); if inner.width() > 4.0 && inner.height() > 4.0 { - if self.editing.as_deref() == Some(id.as_str()) { - self.show_text_editor(scene, doc, index, inner, palette); - } else { + let edited = self.editing.as_deref() == Some(id.as_str()) + && self.show_editor(scene, doc, index, inner, palette); + if !edited { let mut body = scene.new_child( UiBuilder::new() .id_salt(("body", &id)) @@ -898,7 +935,7 @@ impl CanvasView { let mut ctx = BodyContext { palette, base_dir: base_dir.as_deref(), - cache: &mut self.previews, + files: &mut self.files, scale, }; // Contents taller than the node scroll instead of being @@ -949,11 +986,16 @@ impl CanvasView { // nodes here would pull the canvas out from under this very loop, // so `handle_interactions` carries the action out afterwards. response.context_menu(|ui| { + // Whether the file behind the node can be edited is only worth + // working out once the menu is open, because finding out reads + // the file. + let edit = selected <= 1 && self.can_edit_file(doc, &id); node_menu( ui, palette, color.as_ref(), selected, + edit, &mut interactions.menu, ); }); @@ -1050,7 +1092,7 @@ impl CanvasView { } } if response.lost_focus() { - self.editing = None; + self.stop_editing(); doc.end_change(); } } else { @@ -1069,8 +1111,34 @@ impl CanvasView { } } - /// The in-place editor for a text node. - fn show_text_editor( + /// The in-place editor of the node being edited. + /// + /// Returns `false` when the node has nothing to edit here — a link, or a + /// file that is not plain text — so that the caller draws its contents as + /// usual instead. + fn show_editor( + &mut self, + scene: &mut egui::Ui, + doc: &mut Document, + index: usize, + inner: Rect, + palette: &Palette, + ) -> bool { + match doc.canvas.nodes[index].kind { + NodeKind::Text { .. } => { + self.edit_text(scene, doc, index, inner, palette); + true + } + NodeKind::File { .. } => self.edit_file(scene, doc, index, inner, palette), + _ => { + self.stop_editing(); + false + } + } + } + + /// The in-place editor for a text node, which writes into the canvas. + fn edit_text( &mut self, scene: &mut egui::Ui, doc: &mut Document, @@ -1079,7 +1147,6 @@ impl CanvasView { palette: &Palette, ) { let NodeKind::Text { text } = &doc.canvas.nodes[index].kind else { - self.editing = None; return; }; let mut buffer = text.clone(); @@ -1095,7 +1162,7 @@ impl CanvasView { .frame(egui::Frame::NONE) .desired_width(ui.available_width()) .desired_rows(1) - .hint_text(RichText::new("Markdown…").color(palette.text_muted)) + .hint_text(RichText::new("Markdown\u{2026}").color(palette.text_muted)) .font(egui::FontId::proportional(14.0)), ) }); @@ -1112,11 +1179,171 @@ impl CanvasView { } } if response.lost_focus() { - self.editing = None; + self.stop_editing(); doc.end_change(); } } + /// The in-place editor for a file node, which writes into the file itself. + /// + /// The edit stays in [`CanvasView::files`] until the canvas is saved, so + /// typing in a node never touches the disk on its own. Returns `false` for + /// a file this editor will not change; see [`CanvasView::can_edit_file`]. + fn edit_file( + &mut self, + scene: &mut egui::Ui, + doc: &mut Document, + index: usize, + inner: Rect, + palette: &Palette, + ) -> bool { + let node = &doc.canvas.nodes[index]; + let NodeKind::File { file, subpath } = &node.kind else { + return false; + }; + let (id, file, subpath) = (node.id.clone(), file.clone(), subpath.clone()); + let path = nodes::resolve_path(doc.base_dir().as_deref(), &file); + + // The first frame on this node takes a copy of the part of the file it + // shows; from there the editor's own buffer is what is being typed in. + if self + .file_edit + .as_ref() + .is_none_or(|edit| edit.id != id || edit.path != path) + { + let Some((section, buffer)) = self.readable_part(&path, subpath.as_deref()) else { + self.stop_editing(); + return false; + }; + self.file_edit = Some(FileEdit { + id: id.clone(), + path: path.clone(), + section, + buffer, + }); + } + + let Self { + editing, + file_edit, + focus_editor, + body_scroll, + files, + .. + } = self; + let Some(edit) = file_edit.as_mut() else { + return false; + }; + + let mut body = scene.new_child( + UiBuilder::new() + .id_salt(("editor", &id)) + .max_rect(inner) + .layout(Layout::top_down(Align::Min)), + ); + body.set_clip_rect(inner.intersect(scene.clip_rect())); + // A header, so that it is clear the typing goes into the file on disk + // and not into the canvas. + body.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + ui.label(RichText::new("EDIT").size(10.0).color(palette.accent)); + ui.label( + RichText::new(crate::model::file_name(&file)) + .size(13.0) + .strong() + .color(palette.text), + ); + if let Some(subpath) = &subpath { + ui.label(RichText::new(subpath).size(12.0).color(palette.text_muted)); + } + }); + body.add_space(4.0); + + let scroll = body_scroll.get(&id).copied().unwrap_or_default(); + let shown = body_scroll_area(scroll.offset).show(&mut body, |ui| { + ui.add( + TextEdit::multiline(&mut edit.buffer) + .frame(egui::Frame::NONE) + .desired_width(ui.available_width()) + .desired_rows(1) + // The file is shown as it is written, which for anything + // but prose means its columns have to line up. + .font(egui::FontId::monospace(13.0)), + ) + }); + body_scroll.insert(id.clone(), measure(&shown)); + let response = shown.inner; + if *focus_editor { + response.request_focus(); + *focus_editor = false; + } + if response.changed() { + match edit.section.clone() { + // A node showing one section writes that section back into the + // file around it, leaving the rest of it alone. + Some(range) => { + if let Ok(loaded) = files.entry(&edit.path) { + let (text, moved) = files::splice_lines(&loaded.text, range, &edit.buffer); + edit.section = Some(moved); + files.set(&edit.path, text); + } + } + None => files.set(&edit.path, edit.buffer.clone()), + } + } + if response.lost_focus() { + *editing = None; + *file_edit = None; + } + true + } + + /// The text an editor on `path` starts from: the whole file, or the lines + /// the section `subpath` names covers. + /// + /// `None` for a file that cannot be edited here, which is one that is not + /// there, not whole, not text, or does not hold the section asked for. + fn readable_part( + &mut self, + path: &std::path::Path, + subpath: Option<&str>, + ) -> Option<(Option>, String)> { + let loaded = self.files.entry(path).as_ref().ok()?; + if loaded.locked.is_some() { + return None; + } + match subpath { + None => Some((None, loaded.text.clone())), + Some(subpath) => { + let range = files::section_range(&loaded.text, Some(subpath))?; + let text = files::slice_lines(&loaded.text, range.clone()); + Some((Some(range), text)) + } + } + } + + /// Whether the file behind a node is plain text this editor can change. + /// + /// Reads the file the first time it is asked about one, so it is worth + /// calling only when the answer is about to be acted on. + fn can_edit_file(&mut self, doc: &Document, id: &str) -> bool { + let Some(NodeKind::File { file, subpath }) = doc.canvas.node(id).map(|node| &node.kind) + else { + return false; + }; + if nodes::file_kind(file) != FileKind::Text { + return false; + } + let path = nodes::resolve_path(doc.base_dir().as_deref(), file); + let subpath = subpath.clone(); + let Ok(loaded) = self.files.entry(&path) else { + return false; + }; + loaded.locked.is_none() + && (subpath.is_none() + || files::section_range(&loaded.text, subpath.as_deref()).is_some()) + } + fn paint_selection(&self, ui: &egui::Ui, canvas: &Canvas, palette: &Palette) { let painter = ui.painter(); for id in &self.selection.nodes { @@ -1365,7 +1592,7 @@ impl CanvasView { self.selection.set_node(id); } if self.editing.as_deref() != Some(id.as_str()) { - self.editing = None; + self.stop_editing(); } } @@ -1383,7 +1610,7 @@ impl CanvasView { } else if !self.selection.has_node(id) { self.selection.set_node(id); } - self.editing = None; + self.stop_editing(); let ids = self.with_group_members(&doc.canvas, &self.selection.nodes.clone()); let origin = ids .iter() @@ -1427,7 +1654,7 @@ impl CanvasView { if !shift { self.selection.clear(); } - self.editing = None; + self.stop_editing(); self.gesture = Gesture::Band { start, add: shift }; } if let Gesture::Band { start, add } = self.gesture { @@ -1480,7 +1707,7 @@ impl CanvasView { if !shift { self.selection.clear(); } - self.editing = None; + self.stop_editing(); } } } @@ -1506,6 +1733,11 @@ impl CanvasView { self.menu_at = Some(at); } match interactions.menu { + Some(NodeAction::Edit) => { + if let Some(id) = self.selection.only_node().map(str::to_owned) { + self.edit_node(&id); + } + } Some(NodeAction::SetColor(color)) => self.set_selection_color(doc, color), Some(NodeAction::Raise(to_front)) => self.raise_selection(doc, to_front), Some(NodeAction::Delete) => output.status = self.delete_selection(doc), @@ -1513,14 +1745,25 @@ impl CanvasView { } } - /// Opens a file/link node, or starts editing a text node. + /// Opens a file/link node, or starts editing a node whose contents this + /// editor can change. fn activate_node(&mut self, doc: &mut Document, id: &str, output: &mut ViewOutput) { let Some(node) = doc.canvas.node(id) else { return; }; match &node.kind { NodeKind::Text { .. } | NodeKind::Group { .. } => self.edit_node(id), - NodeKind::File { file, .. } => output.open_target = Some(file.clone()), + // A plain-text file is edited here; anything else goes to the + // application that knows what to do with it. Either way the file + // name in the node's header still opens it outside. + NodeKind::File { file, .. } => { + let file = file.clone(); + if self.can_edit_file(doc, id) { + self.edit_node(id); + } else { + output.open_target = Some(file); + } + } NodeKind::Link { url, .. } => output.open_target = Some(url.clone()), NodeKind::Unknown { .. } => {} } @@ -1684,6 +1927,8 @@ enum Pass { /// been drawn, because all three commands rearrange the node list. #[derive(Debug, Clone, PartialEq)] enum NodeAction { + /// Open the in-place editor on the one node the menu was summoned from. + Edit, SetColor(Option), /// `true` brings the selection to the front, `false` sends it to the back. Raise(bool), @@ -1742,8 +1987,22 @@ fn node_menu( palette: &Palette, current: Option<&Color>, selected: usize, + edit: bool, action: &mut Option, ) { + // Double-clicking a file node does this too, which is not something the + // node itself gives away. + if edit { + if ui + .button("Edit contents") + .on_hover_text("Change the file itself, here on the canvas") + .clicked() + { + *action = Some(NodeAction::Edit); + ui.close(); + } + ui.separator(); + } ui.menu_button("Colour", |ui| { for (index, name) in Color::PRESET_NAMES.iter().enumerate() { let preset = Color::Preset(index as u8 + 1); @@ -2149,6 +2408,145 @@ mod tests { pass(ctx, view, doc, screen, vec![button(false)]); } + /// A canvas saved next to `note.md`, with a node pointing at that file. + fn canvas_with_a_file_node( + subpath: Option<&str>, + ) -> (std::path::PathBuf, std::path::PathBuf, Document) { + let dir = + std::env::temp_dir().join(format!("jsoncanvas-edit-{}", crate::model::random_id())); + std::fs::create_dir_all(&dir).unwrap(); + let note = dir.join("note.md"); + std::fs::write(¬e, "# Note\nbody\n\n# Other\nkeep\n").unwrap(); + + let mut doc = Document::new(); + doc.canvas.nodes.push(Node::new( + "f", + NodeKind::File { + file: "note.md".to_owned(), + subpath: subpath.map(str::to_owned), + }, + 0, + 0, + 400, + 300, + )); + // Saved, so that the relative path in the node has a folder to resolve + // against. + doc.save_as(&dir.join("board.canvas")).unwrap(); + (dir, note, doc) + } + + #[test] + fn a_text_file_node_is_edited_in_place_and_written_back() { + 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(); + + assert!(view.edit_file_contents(&doc, "f")); + // The first pass opens the editor and takes the keyboard; the second + // types into it. + pass(&ctx, &mut view, &mut doc, screen, vec![]); + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::Text("!".to_owned())], + ); + + // The edit is held until the canvas is saved, so the file has not + // moved yet. + assert!(view.files.is_dirty(), "the edit was not recorded"); + assert!(view.files.is_edited(¬e)); + assert_eq!( + std::fs::read_to_string(¬e).unwrap(), + "# Note\nbody\n\n# Other\nkeep\n" + ); + // Nor is the canvas itself touched by an edit to one of its files. + assert!(!doc.is_dirty()); + + assert_eq!(view.files.save_all(), (1, Vec::new())); + let written = std::fs::read_to_string(¬e).unwrap(); + assert!( + written.contains('!'), + "typing did not reach the file: {written:?}" + ); + assert!(!view.files.is_dirty()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn editing_a_section_leaves_the_rest_of_the_file_alone() { + 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(Some("#Note")); + let mut view = CanvasView::new(); + + assert!(view.edit_file_contents(&doc, "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"); + assert_eq!(edit.buffer, "# Note\nbody\n"); + assert_eq!(edit.section, Some(0..2)); + + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::Text("!".to_owned())], + ); + view.files.save_all(); + let written = std::fs::read_to_string(¬e).unwrap(); + assert!( + written.contains('!'), + "typing did not reach the file: {written:?}" + ); + assert!( + written.ends_with("\n# Other\nkeep\n"), + "the rest of the file was rewritten: {written:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn only_a_whole_plain_text_file_is_editable() { + 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>| { + doc.canvas.node_mut("f").unwrap().kind = NodeKind::File { + file: file.to_owned(), + subpath: subpath.map(str::to_owned), + }; + }; + + // An image, a file that is not there, and one that is not text. + std::fs::write(dir.join("blob.txt"), [0xff, 0xfe, 0x00]).unwrap(); + for (file, subpath) in [ + ("picture.png", None), + ("gone.md", None), + ("blob.txt", None), + // A section the file does not hold cannot be put back into it. + ("note.md", Some("#Missing")), + ] { + point_at(&mut doc, file, subpath); + assert!( + !view.edit_file_contents(&doc, "f"), + "{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")); + assert!(!view.files.is_edited(¬e), "looking at a file changed it"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn framing_waits_for_the_real_viewport() { let ctx = egui::Context::default();