Files
jsoncanvas-desktop/src/nodes.rs
T
2026-09-09 21:19:44 +02:00

353 lines
11 KiB
Rust

//! 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::markdown;
use crate::model::{Node, NodeKind, 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 {
Image,
Text,
Other,
}
pub fn file_kind(path: &str) -> FileKind {
let ext = Path::new(path)
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "ico" | "tiff" => FileKind::Image,
"md" | "markdown" | "txt" | "text" | "canvas" | "json" | "toml" | "yaml" | "yml"
| "csv" | "log" | "rs" | "py" | "js" | "ts" | "html" | "css" | "sh" => FileKind::Text,
_ => FileKind::Other,
}
}
/// 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);
match base_dir {
Some(base) if path.is_relative() => base.join(path),
_ => path.to_path_buf(),
}
}
/// A `file://` URI for egui's image loader.
///
/// On Windows the loader wants the `file:///C:/dir/file.png` form: without the
/// third slash it reads the drive letter as a UNC host name. It does not
/// percent-decode, so nothing here is escaped.
pub fn file_uri(path: &Path) -> String {
let text = path.to_string_lossy().replace('\\', "/");
if cfg!(windows) {
format!("file:///{}", text.trim_start_matches('/'))
} else {
format!("file://{text}")
}
}
/// Everything the body renderer needs from the outside world.
pub struct BodyContext<'a> {
pub palette: &'a Palette,
pub base_dir: Option<&'a Path>,
pub cache: &'a mut PreviewCache,
/// Current canvas scale, used to skip detail when zoomed far out.
pub scale: f32,
}
/// Draws the contents of `node` inside `ui`, returning a link/file the user
/// activated by clicking.
pub fn render_body(ui: &mut Ui, node: &Node, ctx: &mut BodyContext<'_>) -> Option<String> {
match &node.kind {
NodeKind::Text { text } => render_text(ui, text, ctx),
NodeKind::File { file, subpath } => render_file(ui, file, subpath.as_deref(), ctx),
NodeKind::Link { url } => render_link(ui, url, ctx),
NodeKind::Group { .. } => None,
NodeKind::Unknown { type_name } => {
ui.label(
RichText::new(format!("Unsupported node type “{type_name}”"))
.size(13.0)
.color(ctx.palette.text_muted)
.italics(),
);
ui.label(
RichText::new("Its data is preserved when the file is saved.")
.size(11.0)
.color(ctx.palette.text_muted),
);
None
}
}
}
fn render_text(ui: &mut Ui, text: &str, ctx: &mut BodyContext<'_>) -> Option<String> {
if text.trim().is_empty() {
ui.label(
RichText::new("Empty note")
.size(13.0)
.italics()
.color(ctx.palette.text_muted),
);
return None;
}
let blocks = markdown::parse(text);
markdown::render(ui, &blocks, ctx.palette, 14.0)
}
fn render_file(
ui: &mut Ui,
file: &str,
subpath: Option<&str>,
ctx: &mut BodyContext<'_>,
) -> Option<String> {
let palette = ctx.palette;
let path = resolve_path(ctx.base_dir, file);
let kind = file_kind(file);
let mut activated = None;
// Header: the file name, plus the subpath when the node points inside a file.
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let icon = match kind {
FileKind::Image => "IMG",
FileKind::Text => "DOC",
FileKind::Other => "FILE",
};
ui.label(RichText::new(icon).size(10.0).color(palette.text_muted));
if ui
.link(
RichText::new(file_name(file))
.size(13.0)
.strong()
.color(palette.text),
)
.on_hover_text(path.display().to_string())
.clicked()
{
activated = Some(file.to_owned());
}
if let Some(subpath) = subpath {
ui.label(
RichText::new(subpath)
.size(12.0)
.color(palette.text_muted),
);
}
});
if ctx.scale < 0.4 {
return activated;
}
if !path.exists() {
ui.add_space(4.0);
ui.label(
RichText::new("File not found")
.size(12.0)
.italics()
.color(palette.danger),
);
return activated;
}
ui.add_space(4.0);
match kind {
FileKind::Image => {
ui.add(
egui::Image::new(file_uri(&path))
.maintain_aspect_ratio(true)
.fit_to_exact_size(ui.available_size())
.corner_radius(4.0),
);
}
FileKind::Text => match ctx.cache.text_preview(&path) {
Ok(text) => {
let excerpt = excerpt_for(text, subpath);
let blocks = markdown::parse(&excerpt);
if let Some(link) = markdown::render(ui, &blocks, palette, 13.0) {
activated = Some(link);
}
}
Err(err) => {
ui.label(
RichText::new(format!("Could not read the file: {err}"))
.size(12.0)
.color(palette.danger),
);
}
},
FileKind::Other => {
ui.label(
RichText::new("No preview available")
.size(12.0)
.italics()
.color(palette.text_muted),
);
}
}
activated
}
/// The part of a text file a file node shows: the whole file, or the section a
/// `subpath` (`#heading`) points at.
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")
}
}
fn render_link(ui: &mut Ui, url: &str, ctx: &mut BodyContext<'_>) -> Option<String> {
let palette = ctx.palette;
let mut activated = None;
ui.with_layout(Layout::top_down(Align::Min), |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
ui.label(RichText::new("URL").size(10.0).color(palette.text_muted));
ui.label(
RichText::new(host_of(url))
.size(13.0)
.strong()
.color(palette.text),
);
});
ui.add_space(2.0);
if ui
.link(RichText::new(url).size(12.0).color(palette.accent))
.on_hover_text("Open in the default browser")
.clicked()
{
activated = Some(url.to_owned());
}
});
activated
}
/// The host part of a URL, for the link node header.
fn host_of(url: &str) -> &str {
let rest = url
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(url);
let host = rest.split(['/', '?', '#']).next().unwrap_or(rest);
if host.is_empty() { url } else { host }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_file_kinds() {
assert_eq!(file_kind("a/b/photo.PNG"), FileKind::Image);
assert_eq!(file_kind("notes.md"), FileKind::Text);
assert_eq!(file_kind("archive.zip"), FileKind::Other);
assert_eq!(file_kind("no-extension"), FileKind::Other);
}
#[test]
fn resolves_relative_paths_against_the_canvas_folder() {
let base = Path::new("/vault/boards");
assert_eq!(
resolve_path(Some(base), "notes/a.md"),
PathBuf::from("/vault/boards/notes/a.md")
);
// Absolute paths are left alone.
let absolute = if cfg!(windows) { "C:\\tmp\\a.md" } else { "/tmp/a.md" };
assert_eq!(
resolve_path(Some(base), absolute),
PathBuf::from(absolute)
);
}
#[test]
fn a_subpath_selects_one_section() {
let text = "# One\nalpha\n\n## Two\nbeta\n\n# Three\ngamma";
assert_eq!(excerpt_for(text, Some("#Two")), "## Two\nbeta\n");
assert!(excerpt_for(text, Some("#Missing")).contains("not found"));
assert_eq!(excerpt_for(text, None), text);
}
#[test]
fn image_uris_use_the_form_the_loader_expects() {
let uri = file_uri(Path::new(if cfg!(windows) {
"C:\\vault\\a b.png"
} else {
"/vault/a b.png"
}));
if cfg!(windows) {
assert_eq!(uri, "file:///C:/vault/a b.png");
} else {
assert_eq!(uri, "file:///vault/a b.png");
}
}
#[test]
fn shows_the_host_of_a_link() {
assert_eq!(host_of("https://jsoncanvas.org/spec/1.0/"), "jsoncanvas.org");
assert_eq!(host_of("example.com"), "example.com");
}
}