Allow editing plaintext Files
This commit is contained in:
+108
-21
@@ -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
@@ -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());
|
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"));
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(¬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]
|
#[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();
|
||||||
|
|||||||
Reference in New Issue
Block a user