//! 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>, } impl PreviewCache { pub fn clear(&mut self) { self.text.clear(); } fn text_preview(&mut self, path: &Path) -> &Result { 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 { 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 { 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 { 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 { 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"); } }