gui: Save PDF on every report; short and narrow windows hold up

Reports get a Save PDF button beside Copy for a spreadsheet. It typesets
the report as it's set up on screen (range, months/quarters/years,
comparison) through ledger-import's Typst statement template, the same
one the CPA package uses, in the background, writes it to the Downloads
folder (never the books: the viewer stays read-only), and opens it.

- ledger-import: Statement.percent_from marks percentage columns
  (comparison %, % of income), typeset as 12.5% with no dollar sign;
  wide statements turn the page, and past seven columns set smaller so
  a year of months fits; statement() is public for other callers.
- Compared columns that read the same (Jan 1 - Oct 12 vs a year
  earlier) now say which year, on screen and on paper.
- Short windows: the sidebar's and rail's links scroll above the status
  box instead of under it. The search palette fits the window.
- A test typesets every table-backed report from the demo books,
  plain, by month, and compared (PDF_DIR keeps the files).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-12 20:14:35 -06:00
1 parent 6fb594ed28
commit b582d3d8a0
9 files changed
+444 -101

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
//! Files the viewer makes for you (report PDFs). They go to the
//! Downloads folder, never into the books: the viewer stays read-only.
use std::path::{Path, PathBuf};
/// Where saved files go: `$XDG_DOWNLOAD_DIR`, else `~/Downloads`, else
/// the home directory.
pub fn downloads_dir() -> PathBuf {
if let Some(d) = std::env::var_os("XDG_DOWNLOAD_DIR").map(PathBuf::from)
&& d.is_dir()
{
return d;
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let d = home.join("Downloads");
if d.is_dir() { d } else { home }
}
/// `name` made safe as a file name on every platform.
pub fn file_name(name: &str, ext: &str) -> String {
let clean: String = name
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
c if c.is_control() => ' ',
c => c,
})
.collect();
format!("{}.{ext}", clean.trim())
}
/// Write `bytes` to the downloads folder as `name.ext` (replacing a file
/// of that name: the same report renders the same bytes) and open it with
/// the system's viewer.
pub fn save_and_open(name: &str, ext: &str, bytes: &[u8]) -> Result<PathBuf, String> {
let path = downloads_dir().join(file_name(name, ext));
std::fs::write(&path, bytes).map_err(|e| format!("{}: {e}", path.display()))?;
open(&path);
Ok(path)
}
/// Open a file with whatever the desktop uses for its type. Best effort:
/// the file is saved either way.
fn open(path: &Path) {
#[cfg(target_os = "macos")]
let cmd = std::process::Command::new("open").arg(path).spawn();
#[cfg(target_os = "windows")]
let cmd = std::process::Command::new("cmd")
.args(["/C", "start", ""])
.arg(path)
.spawn();
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let cmd = std::process::Command::new("xdg-open").arg(path).spawn();
let _ = cmd;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_names_are_safe() {
assert_eq!(
file_name("Acme LLC — Profit and loss — Jan 1/2026: draft", "pdf"),
"Acme LLC — Profit and loss — Jan 1-2026- draft.pdf"
);
}
}