Version 1
This commit is contained in:
+352
@@ -0,0 +1,352 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user