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
+108 -21
View File
@@ -79,9 +79,15 @@ impl App {
// -- file actions ----------------------------------------------------- // -- 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. /// Runs `pending` now, or asks about unsaved changes first.
fn guard(&mut self, pending: Pending) -> bool { fn guard(&mut self, pending: Pending) -> bool {
if self.doc.is_dirty() { if self.has_unsaved_changes() {
self.pending = Some(pending); self.pending = Some(pending);
false false
} else { } else {
@@ -130,21 +136,52 @@ impl App {
self.view.zoom_to_fit(&self.doc.canvas); self.view.zoom_to_fit(&self.doc.canvas);
} }
/// Saves, asking for a location when the document has never been saved. /// Saves the canvas, asking for a location when it has never been saved,
/// Returns `true` when the file made it to disk. /// 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 { fn save(&mut self, ctx: &Context) -> bool {
match self.doc.save() { // A canvas that has never been saved is only asked for a location when
Ok(true) => { // it has changes of its own: an edit made through a file node goes to
let name = self.doc.display_name(); // that file and needs nothing from the canvas.
self.set_status(format!("Saved {name}"), ctx); let canvas_pending = self.doc.is_dirty() || self.doc.path().is_some();
true let mut ok = true;
} let mut saved_canvas = false;
Ok(false) => self.save_as(ctx), if canvas_pending {
Err(err) => { match self.doc.save() {
self.error = Some(err); Ok(true) => saved_canvas = true,
false Ok(false) => {
ok = self.save_as(ctx);
saved_canvas = ok;
}
Err(err) => {
self.error = Some(err);
ok = false;
}
} }
} }
// The files second, so that one that will not open for writing cannot
// keep the canvas from being saved.
let (files, errors) = self.view.files.save_all();
if !errors.is_empty() {
self.error = Some(errors.join("\n\n"));
ok = false;
}
let mut saved: Vec<String> = Vec::new();
if saved_canvas {
saved.push(self.doc.display_name());
}
match files {
0 => {}
1 => saved.push("1 file".to_owned()),
n => saved.push(format!("{n} files")),
}
if !saved.is_empty() {
self.set_status(format!("Saved {}", saved.join(" and ")), ctx);
}
ok
} }
fn save_as(&mut self, ctx: &Context) -> bool { fn save_as(&mut self, ctx: &Context) -> bool {
@@ -540,7 +577,7 @@ impl App {
} }
fn request_quit(&mut self, ctx: &Context) { fn request_quit(&mut self, ctx: &Context) {
if self.doc.is_dirty() { if self.has_unsaved_changes() {
self.pending = Some(Pending::Quit); self.pending = Some(Pending::Quit);
} else { } else {
self.quitting = true; self.quitting = true;
@@ -624,9 +661,22 @@ impl App {
self.save_as(ctx); self.save_as(ctx);
} }
ui.separator(); ui.separator();
if ui.button("Reload file previews").clicked() { if ui
self.view.previews.clear(); .button("Reload files")
self.set_status("Reloaded previews", ctx); .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.close();
} }
ui.separator(); ui.separator();
@@ -836,6 +886,22 @@ impl App {
); );
ui.separator(); 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 let Some((message, at)) = &self.status {
if ctx.input(|i| i.time) - at < STATUS_SECONDS { if ctx.input(|i| i.time) - at < STATUS_SECONDS {
ui.label(RichText::new(message).size(12.0).color(palette.text_muted)); ui.label(RichText::new(message).size(12.0).color(palette.text_muted));
@@ -866,11 +932,28 @@ impl App {
fn dialogs(&mut self, ctx: &Context) { fn dialogs(&mut self, ctx: &Context) {
if self.pending.is_some() { if self.pending.is_some() {
let name = self.doc.display_name(); let name = self.doc.display_name();
let edited = self.view.files.dirty_count();
egui::Modal::new(egui::Id::new("unsaved")).show(ctx, |ui| { egui::Modal::new(egui::Id::new("unsaved")).show(ctx, |ui| {
ui.set_width(360.0); ui.set_width(360.0);
ui.heading("Unsaved changes"); ui.heading("Unsaved changes");
ui.add_space(6.0); 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.add_space(12.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button("Save and continue").clicked() { if ui.button("Save and continue").clicked() {
@@ -951,7 +1034,11 @@ impl App {
let title = format!( let title = format!(
"{}{} — JSON Canvas Editor", "{}{} — JSON Canvas Editor",
self.doc.display_name(), self.doc.display_name(),
if self.doc.is_dirty() { " •" } else { "" } if self.has_unsaved_changes() {
" •"
} else {
""
}
); );
if title != self.title { if title != self.title {
ctx.send_viewport_cmd(ViewportCommand::Title(title.clone())); 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. // Closing the window with unsaved changes asks first.
if ctx.input(|i| i.viewport().close_requested()) && !self.quitting { 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); ctx.send_viewport_cmd(ViewportCommand::CancelClose);
self.pending = Some(Pending::Quit); self.pending = Some(Pending::Quit);
} else { } else {
@@ -1045,7 +1132,7 @@ const SHORTCUTS: &[(&str, &str)] = &[
("F", "Zoom to the selection"), ("F", "Zoom to the selection"),
("Escape", "Stop editing, clear the selection"), ("Escape", "Stop editing, clear the selection"),
("Arrow keys", "Nudge (hold Shift for 10 units)"), ("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"), ("Middle / right drag", "Pan the canvas"),
("Ctrl+wheel", "Zoom"), ("Ctrl+wheel", "Zoom"),
("Ctrl+0 / Ctrl+1", "Zoom to fit / actual size"), ("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()); 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.add_space(6.0);
ui.label(field_label(palette, "Subpath")); ui.label(field_label(palette, "Subpath"));
+1
View File
@@ -7,6 +7,7 @@
mod app; mod app;
mod document; mod document;
mod files;
mod geometry; mod geometry;
mod inspector; mod inspector;
mod markdown; mod markdown;
+24 -59
View File
@@ -1,18 +1,15 @@
//! Rendering the *body* of a node: markdown for text nodes, previews for file //! Rendering the *body* of a node: markdown for text nodes, previews for file
//! nodes, the target of link nodes. //! nodes, the target of link nodes.
use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use eframe::egui::{self, Align, Layout, RichText, Ui}; use eframe::egui::{self, Align, Layout, RichText, Ui};
use crate::files::{self, FileStore};
use crate::markdown; use crate::markdown;
use crate::model::{Node, NodeKind, display_text, file_name}; use crate::model::{Node, NodeKind, display_text, file_name};
use crate::theme::Palette; 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. /// What a file node points at.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileKind { 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. /// Resolves a node path against the folder the canvas lives in.
pub fn resolve_path(base_dir: Option<&Path>, raw: &str) -> PathBuf { pub fn resolve_path(base_dir: Option<&Path>, raw: &str) -> PathBuf {
let path = Path::new(raw); let path = Path::new(raw);
@@ -90,7 +58,7 @@ pub fn file_uri(path: &Path) -> String {
pub struct BodyContext<'a> { pub struct BodyContext<'a> {
pub palette: &'a Palette, pub palette: &'a Palette,
pub base_dir: Option<&'a Path>, 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. /// Current canvas scale, used to skip detail when zoomed far out.
pub scale: f32, pub scale: f32,
} }
@@ -143,6 +111,7 @@ fn render_file(
let palette = ctx.palette; let palette = ctx.palette;
let path = resolve_path(ctx.base_dir, file); let path = resolve_path(ctx.base_dir, file);
let kind = file_kind(file); let kind = file_kind(file);
let edited = ctx.files.is_edited(&path);
let mut activated = None; let mut activated = None;
// Header: the file name, plus the subpath when the node points inside a file. // 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 { if let Some(subpath) = subpath {
ui.label(RichText::new(subpath).size(12.0).color(palette.text_muted)); 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 { if ctx.scale < 0.4 {
@@ -196,13 +170,23 @@ fn render_file(
.corner_radius(4.0), .corner_radius(4.0),
); );
} }
FileKind::Text => match ctx.cache.text_preview(&path) { FileKind::Text => match ctx.files.entry(&path) {
Ok(text) => { Ok(loaded) => {
let excerpt = excerpt_for(text, subpath); let excerpt = excerpt_for(&loaded.text, subpath);
let note = loaded.locked.map(files::Locked::reason);
let blocks = markdown::parse(&excerpt); let blocks = markdown::parse(&excerpt);
if let Some(link) = markdown::render(ui, &blocks, palette, 13.0) { if let Some(link) = markdown::render(ui, &blocks, palette, 13.0) {
activated = Some(link); 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) => { Err(err) => {
ui.label( 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 { let Some(heading) = subpath.and_then(|s| s.strip_prefix('#')) else {
return text.to_owned(); return text.to_owned();
}; };
let heading = heading.trim().to_ascii_lowercase(); match files::section_range(text, subpath) {
let mut out = Vec::new(); Some(range) => files::slice_lines(text, range),
let mut level = 0usize; None => format!("*Section \u{201c}{}\u{201d} not found*", heading.trim()),
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")
} }
} }
+419 -21
View File
@@ -2,6 +2,8 @@
//! manipulation gesture (select, move, resize, connect, edit). //! manipulation gesture (select, move, resize, connect, edit).
use std::collections::HashMap; use std::collections::HashMap;
use std::ops::Range;
use std::path::PathBuf;
use eframe::egui::{ use eframe::egui::{
self, Align, Atom, Button, Color32, CornerRadius, CursorIcon, Id, LayerId, Layout, self, Align, Atom, Button, Color32, CornerRadius, CursorIcon, Id, LayerId, Layout,
@@ -11,12 +13,13 @@ use eframe::egui::{
use eframe::emath::TSTransform; use eframe::emath::TSTransform;
use crate::document::Document; use crate::document::Document;
use crate::files::{self, FileStore};
use crate::geometry::{ use crate::geometry::{
anchor, auto_sides, bezier_point, bezier_tangent, bounds_of, curve_between, distance_to_curve, anchor, auto_sides, bezier_point, bezier_tangent, bounds_of, curve_between, distance_to_curve,
edge_curve, node_rect, edge_curve, node_rect,
}; };
use crate::model::{Canvas, Color, Edge, EndShape, Extra, MIN_NODE_SIZE, Node, NodeKind, Side}; 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}; use crate::theme::{self, Palette};
/// Zoom limits. Beyond 3x text starts to look soft, which is the usual /// Zoom limits. Beyond 3x text starts to look soft, which is the usual
@@ -257,6 +260,8 @@ pub struct CanvasView {
gesture: Gesture, gesture: Gesture,
/// Node whose text is being edited in place. /// Node whose text is being edited in place.
editing: Option<String>, 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. /// Set for one frame to move keyboard focus into a freshly opened editor.
focus_editor: bool, focus_editor: bool,
hovered_edge: Option<String>, hovered_edge: Option<String>,
@@ -264,7 +269,8 @@ pub struct CanvasView {
/// Node whose connect handles are on screen, held still during a drag. /// Node whose connect handles are on screen, held still during a drag.
connect_target: Option<String>, connect_target: Option<String>,
pub snap_to_grid: bool, 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. /// How far each node's contents are scrolled, and how far they can go.
body_scroll: HashMap<String, BodyScroll>, body_scroll: HashMap<String, BodyScroll>,
/// Where the open context menu was summoned, in canvas coordinates. The /// Where the open context menu was summoned, in canvas coordinates. The
@@ -278,6 +284,23 @@ pub struct CanvasView {
pending_fit: Option<PendingFit>, 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. /// The scroll position of one node's contents.
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Copy, Default)]
struct BodyScroll { struct BodyScroll {
@@ -310,12 +333,13 @@ impl CanvasView {
selection: Selection::default(), selection: Selection::default(),
gesture: Gesture::None, gesture: Gesture::None,
editing: None, editing: None,
file_edit: None,
focus_editor: false, focus_editor: false,
hovered_edge: None, hovered_edge: None,
hovered_node: None, hovered_node: None,
connect_target: None, connect_target: None,
snap_to_grid: false, snap_to_grid: false,
previews: PreviewCache::default(), files: FileStore::default(),
body_scroll: HashMap::new(), body_scroll: HashMap::new(),
menu_at: None, menu_at: None,
viewport: None, viewport: None,
@@ -327,8 +351,11 @@ impl CanvasView {
self.to_global.scaling 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) { pub fn stop_editing(&mut self) {
self.editing = None; self.editing = None;
self.file_edit = None;
} }
/// Starts in-place editing of a text node or group label. /// Starts in-place editing of a text node or group label.
@@ -337,6 +364,16 @@ impl CanvasView {
self.focus_editor = true; 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 { pub fn centre_in_canvas(&self) -> Pos2 {
self.to_global.inverse() * self.viewport().center() self.to_global.inverse() * self.viewport().center()
} }
@@ -462,7 +499,7 @@ impl CanvasView {
.retain(|e| !self.selection.edges.contains(&e.id)); .retain(|e| !self.selection.edges.contains(&e.id));
let edges = edges_before - doc.canvas.edges.len(); let edges = edges_before - doc.canvas.edges.len();
self.selection.clear(); self.selection.clear();
self.editing = None; self.stop_editing();
Some(format!( Some(format!(
"Deleted {}", "Deleted {}",
count_phrase(&[(nodes, "node"), (edges, "edge")]) count_phrase(&[(nodes, "node"), (edges, "edge")])
@@ -613,7 +650,7 @@ impl CanvasView {
.as_ref() .as_ref()
.is_some_and(|id| doc.canvas.node(id).is_none()) .is_some_and(|id| doc.canvas.node(id).is_none())
{ {
self.editing = None; self.stop_editing();
} }
let outer = ui.available_rect_before_wrap(); let outer = ui.available_rect_before_wrap();
@@ -885,9 +922,9 @@ impl CanvasView {
if scale >= 0.18 && !is_group { if scale >= 0.18 && !is_group {
let inner = rect.shrink(NODE_PADDING); let inner = rect.shrink(NODE_PADDING);
if inner.width() > 4.0 && inner.height() > 4.0 { if inner.width() > 4.0 && inner.height() > 4.0 {
if self.editing.as_deref() == Some(id.as_str()) { let edited = self.editing.as_deref() == Some(id.as_str())
self.show_text_editor(scene, doc, index, inner, palette); && self.show_editor(scene, doc, index, inner, palette);
} else { if !edited {
let mut body = scene.new_child( let mut body = scene.new_child(
UiBuilder::new() UiBuilder::new()
.id_salt(("body", &id)) .id_salt(("body", &id))
@@ -898,7 +935,7 @@ impl CanvasView {
let mut ctx = BodyContext { let mut ctx = BodyContext {
palette, palette,
base_dir: base_dir.as_deref(), base_dir: base_dir.as_deref(),
cache: &mut self.previews, files: &mut self.files,
scale, scale,
}; };
// Contents taller than the node scroll instead of being // 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, // nodes here would pull the canvas out from under this very loop,
// so `handle_interactions` carries the action out afterwards. // so `handle_interactions` carries the action out afterwards.
response.context_menu(|ui| { 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( node_menu(
ui, ui,
palette, palette,
color.as_ref(), color.as_ref(),
selected, selected,
edit,
&mut interactions.menu, &mut interactions.menu,
); );
}); });
@@ -1050,7 +1092,7 @@ impl CanvasView {
} }
} }
if response.lost_focus() { if response.lost_focus() {
self.editing = None; self.stop_editing();
doc.end_change(); doc.end_change();
} }
} else { } else {
@@ -1069,8 +1111,34 @@ impl CanvasView {
} }
} }
/// The in-place editor for a text node. /// The in-place editor of the node being edited.
fn show_text_editor( ///
/// 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, &mut self,
scene: &mut egui::Ui, scene: &mut egui::Ui,
doc: &mut Document, doc: &mut Document,
@@ -1079,7 +1147,6 @@ impl CanvasView {
palette: &Palette, palette: &Palette,
) { ) {
let NodeKind::Text { text } = &doc.canvas.nodes[index].kind else { let NodeKind::Text { text } = &doc.canvas.nodes[index].kind else {
self.editing = None;
return; return;
}; };
let mut buffer = text.clone(); let mut buffer = text.clone();
@@ -1095,7 +1162,7 @@ impl CanvasView {
.frame(egui::Frame::NONE) .frame(egui::Frame::NONE)
.desired_width(ui.available_width()) .desired_width(ui.available_width())
.desired_rows(1) .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)), .font(egui::FontId::proportional(14.0)),
) )
}); });
@@ -1112,11 +1179,171 @@ impl CanvasView {
} }
} }
if response.lost_focus() { if response.lost_focus() {
self.editing = None; self.stop_editing();
doc.end_change(); 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) { fn paint_selection(&self, ui: &egui::Ui, canvas: &Canvas, palette: &Palette) {
let painter = ui.painter(); let painter = ui.painter();
for id in &self.selection.nodes { for id in &self.selection.nodes {
@@ -1365,7 +1592,7 @@ impl CanvasView {
self.selection.set_node(id); self.selection.set_node(id);
} }
if self.editing.as_deref() != Some(id.as_str()) { 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) { } else if !self.selection.has_node(id) {
self.selection.set_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 ids = self.with_group_members(&doc.canvas, &self.selection.nodes.clone());
let origin = ids let origin = ids
.iter() .iter()
@@ -1427,7 +1654,7 @@ impl CanvasView {
if !shift { if !shift {
self.selection.clear(); self.selection.clear();
} }
self.editing = None; self.stop_editing();
self.gesture = Gesture::Band { start, add: shift }; self.gesture = Gesture::Band { start, add: shift };
} }
if let Gesture::Band { start, add } = self.gesture { if let Gesture::Band { start, add } = self.gesture {
@@ -1480,7 +1707,7 @@ impl CanvasView {
if !shift { if !shift {
self.selection.clear(); self.selection.clear();
} }
self.editing = None; self.stop_editing();
} }
} }
} }
@@ -1506,6 +1733,11 @@ impl CanvasView {
self.menu_at = Some(at); self.menu_at = Some(at);
} }
match interactions.menu { 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::SetColor(color)) => self.set_selection_color(doc, color),
Some(NodeAction::Raise(to_front)) => self.raise_selection(doc, to_front), Some(NodeAction::Raise(to_front)) => self.raise_selection(doc, to_front),
Some(NodeAction::Delete) => output.status = self.delete_selection(doc), 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) { fn activate_node(&mut self, doc: &mut Document, id: &str, output: &mut ViewOutput) {
let Some(node) = doc.canvas.node(id) else { let Some(node) = doc.canvas.node(id) else {
return; return;
}; };
match &node.kind { match &node.kind {
NodeKind::Text { .. } | NodeKind::Group { .. } => self.edit_node(id), 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::Link { url, .. } => output.open_target = Some(url.clone()),
NodeKind::Unknown { .. } => {} NodeKind::Unknown { .. } => {}
} }
@@ -1684,6 +1927,8 @@ enum Pass {
/// been drawn, because all three commands rearrange the node list. /// been drawn, because all three commands rearrange the node list.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
enum NodeAction { enum NodeAction {
/// Open the in-place editor on the one node the menu was summoned from.
Edit,
SetColor(Option<Color>), SetColor(Option<Color>),
/// `true` brings the selection to the front, `false` sends it to the back. /// `true` brings the selection to the front, `false` sends it to the back.
Raise(bool), Raise(bool),
@@ -1742,8 +1987,22 @@ fn node_menu(
palette: &Palette, palette: &Palette,
current: Option<&Color>, current: Option<&Color>,
selected: usize, selected: usize,
edit: bool,
action: &mut Option<NodeAction>, 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| { ui.menu_button("Colour", |ui| {
for (index, name) in Color::PRESET_NAMES.iter().enumerate() { for (index, name) in Color::PRESET_NAMES.iter().enumerate() {
let preset = Color::Preset(index as u8 + 1); let preset = Color::Preset(index as u8 + 1);
@@ -2149,6 +2408,145 @@ mod tests {
pass(ctx, view, doc, screen, vec![button(false)]); 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] #[test]
fn framing_waits_for_the_real_viewport() { fn framing_waits_for_the_real_viewport() {
let ctx = egui::Context::default(); let ctx = egui::Context::default();