Allow editing plaintext Files
This commit is contained in:
+104
-17
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
mod app;
|
||||
mod document;
|
||||
mod files;
|
||||
mod geometry;
|
||||
mod inspector;
|
||||
mod markdown;
|
||||
|
||||
+24
-59
@@ -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
@@ -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(¬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();
|
||||
|
||||
Reference in New Issue
Block a user