From 7cc08a0039df5720ec0d690c686256a3cdddf64a Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 23 Oct 2026 09:05:32 -0600 Subject: [PATCH] feat(viewer): Markdown and plain-text documents are drawn as text A text/markdown file (or text/x-markdown) is drawn as formatted text by egui_commonmark, with no image loading, so a document never makes the viewer read a file or the network; any other text/* type as its selectable text. UTF-8 only (a leading byte-order mark dropped), at most 2 MiB; past that or not UTF-8, the viewer says why. The documents page's tile shows the text's first lines. The SHA-256 row is cut to its column (it ran off the panel and widened the page under it). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- Cargo.lock | 35 ++++++ crates/ledger-gui/Cargo.toml | 3 + crates/ledger-gui/src/pages/documents.rs | 14 ++- crates/ledger-gui/src/pages/entry.rs | 24 +++++ crates/ledger-gui/src/receipts.rs | 71 +++++++++++-- crates/ledger-gui/tests/text_documents.rs | 123 ++++++++++++++++++++++ 6 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 crates/ledger-gui/tests/text_documents.rs diff --git a/Cargo.lock b/Cargo.lock index 9d4f4dc..724664b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1826,6 +1826,29 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_commonmark" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e757b2a0639f5f75793e6936ad4c86e8b58ee379d23578ad6924ba9f75ec38" +dependencies = [ + "egui", + "egui_commonmark_backend", + "egui_extras", + "pulldown-cmark", +] + +[[package]] +name = "egui_commonmark_backend" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e182e6b923b0ade1152f67e7d827cbc266fca7782d50249de0c4cad1a2a4e5" +dependencies = [ + "egui", + "egui_extras", + "pulldown-cmark", +] + [[package]] name = "egui_extras" version = "0.36.2" @@ -3887,6 +3910,7 @@ dependencies = [ "eframe", "egui", "egui-phosphor", + "egui_commonmark", "egui_extras", "egui_kittest", "hayro 0.8.0", @@ -5423,6 +5447,17 @@ dependencies = [ "cc", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" +dependencies = [ + "bitflags 2.13.2", + "memchr", + "unicase", +] + [[package]] name = "pxfm" version = "0.1.30" diff --git a/crates/ledger-gui/Cargo.toml b/crates/ledger-gui/Cargo.toml index 4144846..3ef75a4 100644 --- a/crates/ledger-gui/Cargo.toml +++ b/crates/ledger-gui/Cargo.toml @@ -21,6 +21,9 @@ eframe = { version = "0.36", default-features = false, features = ["default_font egui = "0.36" egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] } egui-phosphor = "0.14" +# Markdown documents, drawn as text. No image loading (no `load-images`, +# no `fetch`): a document never makes the viewer read a file or the network. +egui_commonmark = { version = "0.25", default-features = false, features = ["pulldown_cmark"] } hayro = { version = "0.8", features = ["embed-fonts"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } notify = "8" diff --git a/crates/ledger-gui/src/pages/documents.rs b/crates/ledger-gui/src/pages/documents.rs index 92d24b1..278b7c4 100644 --- a/crates/ledger-gui/src/pages/documents.rs +++ b/crates/ledger-gui/src/pages/documents.rs @@ -157,6 +157,18 @@ fn tile( .image(tex.id(), img_rect, uv, egui::Color32::WHITE); } } + Render::Text { text, .. } => { + // The first lines of the text, as written, clipped to the thumb. + let head: String = text.lines().take(14).collect::>().join("\n"); + let galley = + ui.painter() + .layout(head, theme::regular(10.5), p.muted, thumb.width() - 16.0); + ui.painter().with_clip_rect(thumb.shrink(4.0)).galley( + thumb.min + vec2(8.0, 8.0), + galley, + p.muted, + ); + } Render::Loading => { ui.painter().text( thumb.center(), @@ -273,7 +285,7 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) { ui.label(fmt::timestamp(&a.created_at)); ui.end_row(); k(ui, "SHA-256"); - widgets::copyable( + widgets::copyable_truncated( ui, RichText::new(&a.blob_sha).monospace().size(11.5), &a.blob_sha, diff --git a/crates/ledger-gui/src/pages/entry.rs b/crates/ledger-gui/src/pages/entry.rs index 0e22e3f..60b5f35 100644 --- a/crates/ledger-gui/src/pages/entry.rs +++ b/crates/ledger-gui/src/pages/entry.rs @@ -631,6 +631,30 @@ pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, wid .color(p.negative), ); } + Render::Text { text, markdown } => { + let (text, markdown) = (text.clone(), *markdown); + egui::Frame::new() + .fill(p.surface) + .stroke(Stroke::new(1.0, p.border)) + .corner_radius(CornerRadius::same(6)) + .inner_margin(egui::Margin::same(14)) + .show(ui, |ui| { + ui.set_width(width - 30.0); + if markdown { + egui_commonmark::CommonMarkViewer::new() + .max_image_width(Some((width - 30.0) as usize)) + .show(ui, &mut shell.receipts.markdown, &text); + } else { + ui.add( + egui::Label::new( + RichText::new(text).font(egui::FontId::monospace(12.0)), + ) + .selectable(true) + .wrap(), + ); + } + }); + } Render::Ready(pages) => { for (i, tex) in pages.iter().enumerate() { let size = tex.size_vec2(); diff --git a/crates/ledger-gui/src/receipts.rs b/crates/ledger-gui/src/receipts.rs index 5597fbd..e327c3e 100644 --- a/crates/ledger-gui/src/receipts.rs +++ b/crates/ledger-gui/src/receipts.rs @@ -1,7 +1,9 @@ //! Rendering receipts and documents to textures, off the UI thread. //! //! PDFs are rasterized with `hayro` (pure Rust), images decoded with -//! `image`. Results are cached by blob hash: the same bytes never render +//! `image`; Markdown and plain text are read as UTF-8 and drawn as text +//! (Markdown by `egui_commonmark`), so they follow the theme and select +//! like any text. Results are cached by blob hash: the same bytes never render //! twice, and a hash is the content, so the cache never goes stale. Bytes //! are read through the verifying blob store: a corrupt blob shows as an //! error, never as a picture. @@ -15,9 +17,24 @@ use std::sync::mpsc::{Receiver, Sender, channel}; pub enum State { Loading, Ready(Vec), + /// A text file: its UTF-8 text, and whether it is Markdown. + Text { + text: String, + markdown: bool, + }, Failed(String), } +/// What a render made: pages as pictures, or a text file's text. +enum Rendered { + Pages(Vec), + Text { text: String, markdown: bool }, +} + +/// The largest text file shown as text: past it the viewer says so +/// rather than lay out megabytes of text every frame. +pub const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024; + struct Job { sha: String, mime: String, @@ -26,11 +43,13 @@ struct Job { struct Done { sha: String, - result: Result, String>, + result: Result, } pub struct Receipts { cache: HashMap, + /// egui_commonmark's layout cache, shared by every Markdown file. + pub markdown: egui_commonmark::CommonMarkCache, jobs: Sender, done: Receiver, } @@ -68,6 +87,7 @@ impl Receipts { } Self { cache: HashMap::new(), + markdown: egui_commonmark::CommonMarkCache::default(), jobs, done, } @@ -95,7 +115,8 @@ impl Receipts { fn accept(&mut self, ctx: &Context, done: Done) { let state = match done.result { - Ok(images) => State::Ready( + Ok(Rendered::Text { text, markdown }) => State::Text { text, markdown }, + Ok(Rendered::Pages(images)) => State::Ready( images .into_iter() .enumerate() @@ -129,21 +150,59 @@ impl Receipts { } } -fn render(job: &Job) -> Result, String> { +fn render(job: &Job) -> Result { let bytes = BlobStore::open_read_only(&job.root) .get(&job.sha) .map_err(|e| e.to_string())?; let is_pdf = job.mime == "application/pdf" || bytes.starts_with(b"%PDF"); if is_pdf { - render_pdf(bytes) + render_pdf(bytes).map(Rendered::Pages) } else if job.mime.starts_with("image/") { let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?; - Ok(vec![to_color_image(img.to_rgba8())]) + Ok(Rendered::Pages(vec![to_color_image(img.to_rgba8())])) + } else if let Some(markdown) = text_kind(&job.mime) { + let text = text_of(bytes)?; + Ok(Rendered::Text { text, markdown }) } else { Err(format!("{} can't be previewed", job.mime)) } } +/// Whether a media type is text the viewer shows as text: `Some(true)` +/// for Markdown (`text/markdown`, RFC 7763, and the older +/// `text/x-markdown`), `Some(false)` for other `text/*` types, `None` +/// otherwise. Parameters (`; charset=utf-8`) and case are ignored. +pub fn text_kind(mime: &str) -> Option { + let essence = mime + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + match essence.as_str() { + "text/markdown" | "text/x-markdown" => Some(true), + t if t.starts_with("text/") => Some(false), + _ => None, + } +} + +/// A text file's text: UTF-8 (a leading byte-order mark dropped), at most +/// [`MAX_TEXT_BYTES`]; anything else is refused with why, never shown +/// half-decoded. +pub fn text_of(bytes: Vec) -> Result { + if bytes.len() > MAX_TEXT_BYTES { + return Err(format!( + "the text is {} bytes, more than the {MAX_TEXT_BYTES} shown", + bytes.len() + )); + } + let mut text = String::from_utf8(bytes).map_err(|_| "the text isn't UTF-8".to_string())?; + if text.starts_with('\u{feff}') { + text.drain(..'\u{feff}'.len_utf8()); + } + Ok(text) +} + fn render_pdf(bytes: Vec) -> Result, String> { use hayro::hayro_interpret::InterpreterSettings; use hayro::hayro_syntax::Pdf; diff --git a/crates/ledger-gui/tests/text_documents.rs b/crates/ledger-gui/tests/text_documents.rs new file mode 100644 index 0000000..2758556 --- /dev/null +++ b/crates/ledger-gui/tests/text_documents.rs @@ -0,0 +1,123 @@ +//! Text documents in the viewer: a Markdown file is drawn as formatted +//! text, a plain-text file as its text, both on the document's page and +//! on its tile; a text file that isn't UTF-8 says so rather than show +//! half of it. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ledger_core::Currency; +use ledger_gui::nav::Route; +use ledger_gui::{App, Source, data}; +use ledger_store::Store; +use std::path::PathBuf; + +struct TempDir(PathBuf); + +impl TempDir { + fn new(tag: &str) -> TempDir { + let d = std::env::temp_dir().join(format!("ledger-gui-text-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + TempDir(d) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +const PLAN: &str = "\u{feff}# Studio plan\n\nThe studio's goal is **ten apps** that earn.\n\n\ +- Chess openings\n- Route planner\n"; + +fn open(dir: &TempDir, route: Route) -> Harness<'static, App> { + let books = data::load_once(&Source { + data_dir: dir.0.join("books-data"), + today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 23).unwrap()), + }) + .unwrap(); + let mut h = Harness::builder() + .with_size(egui::vec2(1100.0, 900.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route)); + h.run_steps(3); + let ctx = h.ctx.clone(); + h.state_mut().settle(&ctx); + h.run_steps(3); + h +} + +/// SHOT_DIR set: the page as drawn, to look at. +fn shot(h: &mut Harness<'static, App>, name: &str) { + if let Ok(out) = std::env::var("SHOT_DIR") { + let out = PathBuf::from(out); + std::fs::create_dir_all(&out).unwrap(); + h.render() + .unwrap() + .save(out.join(format!("{name}.png"))) + .unwrap(); + } +} + +#[test] +fn a_markdown_document_is_drawn_as_formatted_text() { + let dir = TempDir::new("md"); + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + let (md, _, _) = s + .file_document( + "other", + "plan.md", + "text/markdown", + PLAN.as_bytes(), + Some("Plan for the studio"), + ) + .unwrap(); + drop(s); + let mut h = open(&dir, Route::Document(md)); + shot(&mut h, "markdown-document"); + // The heading is its own text, without the "#" or the byte-order mark; + // the list items are there; nothing says it can't be shown. + assert!(h.query_by_label("Studio plan").is_some()); + assert!(h.query_by_label_contains("# Studio").is_none()); + assert!(h.query_by_label_contains("Chess openings").is_some()); + assert!(h.query_by_label_contains("Route planner").is_some()); + assert!(h.query_by_label_contains("can't be previewed").is_none()); + assert!(h.query_by_label_contains("Can't show").is_none()); +} + +#[test] +fn a_plain_text_document_is_its_text_and_bad_text_says_so() { + let dir = TempDir::new("txt"); + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + let (txt, _, _) = s + .file_document( + "other", + "notes.txt", + "text/plain; charset=utf-8", + b"Meeting notes\nall agreed", + None, + ) + .unwrap(); + let (bad, _, _) = s + .file_document("other", "bad.md", "text/markdown", b"caf\xe9", None) + .unwrap(); + drop(s); + let h = open(&dir, Route::Document(txt)); + assert!(h.query_by_label_contains("Meeting notes").is_some()); + assert!(h.query_by_label_contains("Can't show").is_none()); + let h = open(&dir, Route::Document(bad)); + assert!(h.query_by_label_contains("the text isn't UTF-8").is_some()); +} + +#[test] +fn the_documents_page_draws_text_tiles() { + let dir = TempDir::new("tiles"); + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + s.file_document("other", "plan.md", "text/markdown", PLAN.as_bytes(), None) + .unwrap(); + drop(s); + let mut h = open(&dir, Route::Documents); + h.run_steps(2); + shot(&mut h, "text-tiles"); + assert_eq!(h.state_mut().route(), &Route::Documents); +}