Allow editing plaintext Files

This commit is contained in:
2026-09-10 14:46:57 +02:00
parent 4884aca3dd
commit 79cb3301b5
6 changed files with 914 additions and 101 deletions
+104 -17
View File
@@ -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,23 +136,54 @@ 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 {
// 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) => {
let name = self.doc.display_name();
self.set_status(format!("Saved {name}"), ctx);
true
Ok(true) => saved_canvas = true,
Ok(false) => {
ok = self.save_as(ctx);
saved_canvas = ok;
}
Ok(false) => self.save_as(ctx),
Err(err) => {
self.error = Some(err);
false
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"])
@@ -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"),
+349
View File
@@ -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<Locked>,
/// 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<PathBuf, Result<Loaded, String>>,
}
impl FileStore {
/// The text of `path`, reading it the first time it is asked for.
pub fn entry(&mut self, path: &Path) -> &Result<Loaded, String> {
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<String>) {
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<PathBuf> = 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<Loaded, String> {
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<Range<usize>> {
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<usize>) -> 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<usize>, replacement: &str) -> (String, Range<usize>) {
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<dyn std::error::Error>>
{
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<dyn std::error::Error>> {
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(())
}
}
+13
View File
@@ -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"));
+1
View File
@@ -7,6 +7,7 @@
mod app;
mod document;
mod files;
mod geometry;
mod inspector;
mod markdown;
+24 -59
View File
@@ -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<PathBuf, Result<String, String>>,
}
impl PreviewCache {
pub fn clear(&mut self) {
self.text.clear();
}
fn text_preview(&mut self, path: &Path) -> &Result<String, String> {
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()),
}
}
+419 -21
View File
@@ -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<String>,
/// The text an open file-node editor is working on, while one is open.
file_edit: Option<FileEdit>,
/// Set for one frame to move keyboard focus into a freshly opened editor.
focus_editor: bool,
hovered_edge: Option<String>,
@@ -264,7 +269,8 @@ pub struct CanvasView {
/// Node whose connect handles are on screen, held still during a drag.
connect_target: Option<String>,
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<String, BodyScroll>,
/// Where the open context menu was summoned, in canvas coordinates. The
@@ -278,6 +284,23 @@ pub struct CanvasView {
pending_fit: Option<PendingFit>,
}
/// 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<Range<usize>>,
/// 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<Range<usize>>, 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<Color>),
/// `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<NodeAction>,
) {
// 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(&note, "# 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(&note));
assert_eq!(
std::fs::read_to_string(&note).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(&note).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(&note).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(&note), "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();