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:
1 parent
ea996f6e7e
commit
12308fe3e9
9 files changed
+444
-101
No files matched your search
@@ -14,6 +14,7 @@ path = "src/main.rs"
|
|||||||
ledger-core = { path = "../ledger-core" }
|
ledger-core = { path = "../ledger-core" }
|
||||||
ledger-store = { path = "../ledger-store" }
|
ledger-store = { path = "../ledger-store" }
|
||||||
ledger-config = { path = "../ledger-config" }
|
ledger-config = { path = "../ledger-config" }
|
||||||
|
ledger-import = { path = "../ledger-import" }
|
||||||
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11", "persistence"] }
|
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11", "persistence"] }
|
||||||
egui = "0.36"
|
egui = "0.36"
|
||||||
egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] }
|
egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] }
|
||||||
|
|||||||
+118
-90
@@ -281,61 +281,74 @@ impl App {
|
|||||||
ui.add_space(18.0);
|
ui.add_space(18.0);
|
||||||
let current = self.nav.current.section();
|
let current = self.nav.current.section();
|
||||||
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
|
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
|
||||||
for s in Section::ALL {
|
// Short windows: the links scroll above the status box
|
||||||
let on = s == current;
|
// instead of the box drawing over them.
|
||||||
let (rect, resp) =
|
let links_h = (ui.available_height() - SIDEBAR_FOOTER).max(64.0);
|
||||||
ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
|
egui::ScrollArea::vertical()
|
||||||
if on {
|
.id_salt("sidebar_links")
|
||||||
ui.painter()
|
.max_height(links_h)
|
||||||
.rect_filled(rect, CornerRadius::same(8), p.surface);
|
.show(ui, |ui| {
|
||||||
ui.painter().rect_stroke(
|
for s in Section::ALL {
|
||||||
rect,
|
let on = s == current;
|
||||||
CornerRadius::same(8),
|
let (rect, resp) = ui.allocate_exact_size(
|
||||||
Stroke::new(1.0, p.border),
|
vec2(ui.available_width(), 32.0),
|
||||||
egui::StrokeKind::Inside,
|
Sense::click(),
|
||||||
);
|
);
|
||||||
} else if resp.hovered() {
|
if on {
|
||||||
ui.painter()
|
ui.painter()
|
||||||
.rect_filled(rect, CornerRadius::same(8), p.hover);
|
.rect_filled(rect, CornerRadius::same(8), p.surface);
|
||||||
}
|
ui.painter().rect_stroke(
|
||||||
let color = if on { p.text } else { p.muted };
|
rect,
|
||||||
ui.painter().text(
|
CornerRadius::same(8),
|
||||||
rect.left_center() + vec2(10.0, 0.0),
|
Stroke::new(1.0, p.border),
|
||||||
egui::Align2::LEFT_CENTER,
|
egui::StrokeKind::Inside,
|
||||||
s.icon(),
|
);
|
||||||
egui::FontId::proportional(16.0),
|
} else if resp.hovered() {
|
||||||
if on { p.accent } else { p.muted },
|
ui.painter()
|
||||||
);
|
.rect_filled(rect, CornerRadius::same(8), p.hover);
|
||||||
ui.painter().text(
|
}
|
||||||
rect.left_center() + vec2(36.0, 0.0),
|
let color = if on { p.text } else { p.muted };
|
||||||
egui::Align2::LEFT_CENTER,
|
ui.painter().text(
|
||||||
s.title(),
|
rect.left_center() + vec2(10.0, 0.0),
|
||||||
if on {
|
egui::Align2::LEFT_CENTER,
|
||||||
theme::semibold(13.5)
|
s.icon(),
|
||||||
} else {
|
egui::FontId::proportional(16.0),
|
||||||
theme::regular(13.5)
|
if on { p.accent } else { p.muted },
|
||||||
},
|
);
|
||||||
color,
|
ui.painter().text(
|
||||||
);
|
rect.left_center() + vec2(36.0, 0.0),
|
||||||
if s == Section::Overview && attention > 0 {
|
egui::Align2::LEFT_CENTER,
|
||||||
let badge = egui::Rect::from_center_size(
|
s.title(),
|
||||||
rect.right_center() - vec2(18.0, 0.0),
|
if on {
|
||||||
vec2(22.0, 18.0),
|
theme::semibold(13.5)
|
||||||
);
|
} else {
|
||||||
ui.painter()
|
theme::regular(13.5)
|
||||||
.rect_filled(badge, CornerRadius::same(9), p.warning_soft);
|
},
|
||||||
ui.painter().text(
|
color,
|
||||||
badge.center(),
|
);
|
||||||
egui::Align2::CENTER_CENTER,
|
if s == Section::Overview && attention > 0 {
|
||||||
attention.to_string(),
|
let badge = egui::Rect::from_center_size(
|
||||||
theme::semibold(11.0),
|
rect.right_center() - vec2(18.0, 0.0),
|
||||||
p.warning,
|
vec2(22.0, 18.0),
|
||||||
);
|
);
|
||||||
}
|
ui.painter().rect_filled(
|
||||||
if resp.clicked() {
|
badge,
|
||||||
self.nav.go(s.home());
|
CornerRadius::same(9),
|
||||||
}
|
p.warning_soft,
|
||||||
}
|
);
|
||||||
|
ui.painter().text(
|
||||||
|
badge.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
attention.to_string(),
|
||||||
|
theme::semibold(11.0),
|
||||||
|
p.warning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if resp.clicked() {
|
||||||
|
self.nav.go(s.home());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
||||||
let label = if self.dark {
|
let label = if self.dark {
|
||||||
format!("{} Light mode", icon::SUN)
|
format!("{} Light mode", icon::SUN)
|
||||||
@@ -403,41 +416,52 @@ impl App {
|
|||||||
ui.add_space(18.0);
|
ui.add_space(18.0);
|
||||||
let current = self.nav.current.section();
|
let current = self.nav.current.section();
|
||||||
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
|
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
|
||||||
for s in Section::ALL {
|
let links_h = (ui.available_height() - RAIL_FOOTER).max(72.0);
|
||||||
let on = s == current;
|
egui::ScrollArea::vertical()
|
||||||
let (rect, resp) =
|
.id_salt("rail_links")
|
||||||
ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click());
|
.max_height(links_h)
|
||||||
if on {
|
.show(ui, |ui| {
|
||||||
ui.painter()
|
for s in Section::ALL {
|
||||||
.rect_filled(rect, CornerRadius::same(8), p.surface);
|
let on = s == current;
|
||||||
ui.painter().rect_stroke(
|
let (rect, resp) = ui.allocate_exact_size(
|
||||||
rect,
|
vec2(ui.available_width(), 36.0),
|
||||||
CornerRadius::same(8),
|
Sense::click(),
|
||||||
Stroke::new(1.0, p.border),
|
);
|
||||||
egui::StrokeKind::Inside,
|
if on {
|
||||||
);
|
ui.painter()
|
||||||
} else if resp.hovered() {
|
.rect_filled(rect, CornerRadius::same(8), p.surface);
|
||||||
ui.painter()
|
ui.painter().rect_stroke(
|
||||||
.rect_filled(rect, CornerRadius::same(8), p.hover);
|
rect,
|
||||||
}
|
CornerRadius::same(8),
|
||||||
ui.painter().text(
|
Stroke::new(1.0, p.border),
|
||||||
rect.center(),
|
egui::StrokeKind::Inside,
|
||||||
egui::Align2::CENTER_CENTER,
|
);
|
||||||
s.icon(),
|
} else if resp.hovered() {
|
||||||
egui::FontId::proportional(18.0),
|
ui.painter()
|
||||||
if on { p.accent } else { p.muted },
|
.rect_filled(rect, CornerRadius::same(8), p.hover);
|
||||||
);
|
}
|
||||||
let tip = if s == Section::Overview && attention > 0 {
|
ui.painter().text(
|
||||||
ui.painter()
|
rect.center(),
|
||||||
.circle_filled(rect.center() + vec2(9.0, -8.0), 4.0, p.warning);
|
egui::Align2::CENTER_CENTER,
|
||||||
format!("{} · {attention} need attention", s.title())
|
s.icon(),
|
||||||
} else {
|
egui::FontId::proportional(18.0),
|
||||||
s.title().to_string()
|
if on { p.accent } else { p.muted },
|
||||||
};
|
);
|
||||||
if resp.on_hover_text(tip).clicked() {
|
let tip = if s == Section::Overview && attention > 0 {
|
||||||
self.nav.go(s.home());
|
ui.painter().circle_filled(
|
||||||
}
|
rect.center() + vec2(9.0, -8.0),
|
||||||
}
|
4.0,
|
||||||
|
p.warning,
|
||||||
|
);
|
||||||
|
format!("{} · {attention} need attention", s.title())
|
||||||
|
} else {
|
||||||
|
s.title().to_string()
|
||||||
|
};
|
||||||
|
if resp.on_hover_text(tip).clicked() {
|
||||||
|
self.nav.go(s.home());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
|
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
|
||||||
let (glyph, tip) = if self.dark {
|
let (glyph, tip) = if self.dark {
|
||||||
(icon::SUN, "Light mode")
|
(icon::SUN, "Light mode")
|
||||||
@@ -616,6 +640,10 @@ impl App {
|
|||||||
|
|
||||||
/// Width of the icon rail.
|
/// Width of the icon rail.
|
||||||
const RAIL_W: f32 = 60.0;
|
const RAIL_W: f32 = 60.0;
|
||||||
|
/// Height kept clear under the sidebar's links for the status box and the
|
||||||
|
/// theme toggle; the rail's equivalent.
|
||||||
|
const SIDEBAR_FOOTER: f32 = 172.0;
|
||||||
|
const RAIL_FOOTER: f32 = 84.0;
|
||||||
|
|
||||||
fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
|
fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
|
||||||
let p = pal(ui.ctx());
|
let p = pal(ui.ctx());
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ pub mod books;
|
|||||||
mod charts;
|
mod charts;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod demo;
|
pub mod demo;
|
||||||
|
mod export;
|
||||||
mod fmt;
|
mod fmt;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod pages;
|
pub mod pages;
|
||||||
|
|||||||
@@ -103,6 +103,32 @@ pub struct State {
|
|||||||
pub range: Range,
|
pub range: Range,
|
||||||
pub columns: Columns,
|
pub columns: Columns,
|
||||||
pub compare: Compare,
|
pub compare: Compare,
|
||||||
|
/// A PDF being typeset in the background.
|
||||||
|
pdf_job: Option<std::sync::mpsc::Receiver<Result<std::path::PathBuf, String>>>,
|
||||||
|
/// How the last PDF went, and when (to fade the message).
|
||||||
|
pdf_done: Option<(Result<std::path::PathBuf, String>, f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a PDF of the report on screen needs besides the table.
|
||||||
|
struct PdfInfo {
|
||||||
|
title: String,
|
||||||
|
period: String,
|
||||||
|
meta: ledger_import::ReportMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PdfInfo {
|
||||||
|
fn new(b: &Books, title: &str, period: &str) -> Self {
|
||||||
|
PdfInfo {
|
||||||
|
title: title.to_string(),
|
||||||
|
period: period.to_string(),
|
||||||
|
meta: ledger_import::ReportMeta {
|
||||||
|
company: b.company_name().to_string(),
|
||||||
|
basis: b.settings.company.basis.clone(),
|
||||||
|
prepared_on: b.today,
|
||||||
|
journal_seq: b.seq(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
@@ -247,6 +273,49 @@ impl Table {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The table as a statement for the PDF typesetter: sections spaced
|
||||||
|
/// apart, sums ruled and signed, notes as italic subheadings. Detail
|
||||||
|
/// lines (the accounts under a tax line) don't carry over.
|
||||||
|
fn statement(&self, title: &str, period: &str) -> ledger_import::Statement {
|
||||||
|
use ledger_import::{Row as PdfRow, RowKind};
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for r in &self.rows {
|
||||||
|
let kind = match r.kind {
|
||||||
|
Kind::Section => RowKind::Section,
|
||||||
|
Kind::Line => RowKind::Line,
|
||||||
|
Kind::Subtotal => RowKind::Subtotal,
|
||||||
|
Kind::Grand => RowKind::Total,
|
||||||
|
Kind::Note => RowKind::Subheading,
|
||||||
|
};
|
||||||
|
if matches!(kind, RowKind::Section | RowKind::Subheading) && !rows.is_empty() {
|
||||||
|
rows.push(PdfRow {
|
||||||
|
kind: RowKind::Spacer,
|
||||||
|
label: String::new(),
|
||||||
|
amounts: Vec::new(),
|
||||||
|
currency: false,
|
||||||
|
rule_above: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Icon glyphs (private use) aren't in the PDF's font.
|
||||||
|
let label: String = r
|
||||||
|
.label
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c))
|
||||||
|
.collect();
|
||||||
|
rows.push(PdfRow {
|
||||||
|
kind,
|
||||||
|
label: label.trim().to_string(),
|
||||||
|
amounts: r.cells.iter().map(|c| c.value).collect(),
|
||||||
|
currency: false,
|
||||||
|
rule_above: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let columns: Vec<&str> = self.columns.iter().map(String::as_str).collect();
|
||||||
|
let mut st = ledger_import::report::statement(title, period.to_string(), &columns, rows);
|
||||||
|
st.percent_from = self.pct_from;
|
||||||
|
st
|
||||||
|
}
|
||||||
|
|
||||||
fn tsv(&self) -> String {
|
fn tsv(&self) -> String {
|
||||||
let mut out = String::from("\t");
|
let mut out = String::from("\t");
|
||||||
out.push_str(&self.columns.join("\t"));
|
out.push_str(&self.columns.join("\t"));
|
||||||
@@ -295,6 +364,11 @@ fn column_ranges(st: &State, b: &Books) -> Vec<(NaiveDate, NaiveDate, String)> {
|
|||||||
let mut v = vec![(s, e, col_title(s, e, Columns::Total))];
|
let mut v = vec![(s, e, col_title(s, e, Columns::Total))];
|
||||||
if let Some((ps, pe)) = prior(st, s, e) {
|
if let Some((ps, pe)) = prior(st, s, e) {
|
||||||
v.push((ps, pe, col_title(ps, pe, Columns::Total)));
|
v.push((ps, pe, col_title(ps, pe, Columns::Total)));
|
||||||
|
// Same days a year apart read the same: say which year.
|
||||||
|
if v[0].2 == v[1].2 {
|
||||||
|
v[0].2 = format!("{}, {}", v[0].2, e.year());
|
||||||
|
v[1].2 = format!("{}, {}", v[1].2, pe.year());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
@@ -1087,6 +1161,7 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picke
|
|||||||
} else {
|
} else {
|
||||||
Compare::None
|
Compare::None
|
||||||
},
|
},
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let cols = column_ranges(&state, b);
|
let cols = column_ranges(&state, b);
|
||||||
let compare = state.compare != Compare::None;
|
let compare = state.compare != Compare::None;
|
||||||
@@ -1190,19 +1265,55 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picke
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(t) = table {
|
if let Some(t) = table {
|
||||||
render(ui, &t, shell);
|
render(ui, &t, &PdfInfo::new(b, kind.title(), &subtitle), shell);
|
||||||
}
|
}
|
||||||
if let Some(k) = pick {
|
if let Some(k) = pick {
|
||||||
shell.go(Route::Reports(k));
|
shell.go(Route::Reports(k));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(ui: &mut Ui, t: &Table, shell: &mut Shell<'_>) {
|
fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) {
|
||||||
let p = pal(ui.ctx());
|
let p = pal(ui.ctx());
|
||||||
let tsv = t.tsv();
|
let tsv = t.tsv();
|
||||||
let mut go: Option<Drill> = None;
|
let mut go: Option<Drill> = None;
|
||||||
|
let st = &mut shell.state.reports;
|
||||||
|
if let Some(rx) = &st.pdf_job {
|
||||||
|
if let Ok(done) = rx.try_recv() {
|
||||||
|
st.pdf_job = None;
|
||||||
|
st.pdf_done = Some((done, ui.input(|i| i.time)));
|
||||||
|
} else {
|
||||||
|
ui.ctx()
|
||||||
|
.request_repaint_after(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut save_pdf = false;
|
||||||
widgets::card(ui, |ui| {
|
widgets::card(ui, |ui| {
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||||
|
let busy = st.pdf_job.is_some();
|
||||||
|
let label = if busy {
|
||||||
|
format!("{} Making PDF…", icon::HOURGLASS)
|
||||||
|
} else {
|
||||||
|
format!("{} Save PDF", icon::FILE_PDF)
|
||||||
|
};
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
!busy,
|
||||||
|
egui::Button::new(
|
||||||
|
RichText::new(label)
|
||||||
|
.font(theme::regular(12.0))
|
||||||
|
.color(p.muted),
|
||||||
|
)
|
||||||
|
.frame(false),
|
||||||
|
)
|
||||||
|
.on_hover_text(format!(
|
||||||
|
"Typesets this report as it's set up here and saves it to {}, then opens it",
|
||||||
|
crate::export::downloads_dir().display()
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
save_pdf = true;
|
||||||
|
}
|
||||||
|
ui.add_space(8.0);
|
||||||
if ui
|
if ui
|
||||||
.add(
|
.add(
|
||||||
egui::Button::new(
|
egui::Button::new(
|
||||||
@@ -1218,6 +1329,31 @@ fn render(ui: &mut Ui, t: &Table, shell: &mut Shell<'_>) {
|
|||||||
ui.ctx().copy_text(tsv.clone());
|
ui.ctx().copy_text(tsv.clone());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if let Some((done, at)) = &st.pdf_done {
|
||||||
|
let now = ui.input(|i| i.time);
|
||||||
|
if now - at < 8.0 {
|
||||||
|
let (text, color) = match done {
|
||||||
|
Ok(path) => (
|
||||||
|
format!("{} Saved {}", icon::CHECK, path.display()),
|
||||||
|
p.positive,
|
||||||
|
),
|
||||||
|
Err(e) => (
|
||||||
|
format!("{} PDF failed: {e}", icon::WARNING_CIRCLE),
|
||||||
|
p.negative,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(
|
||||||
|
RichText::new(text).font(theme::regular(11.5)).color(color),
|
||||||
|
)
|
||||||
|
.truncate(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.ctx()
|
||||||
|
.request_repaint_after(std::time::Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
}
|
||||||
let n = t.columns.len().max(1) as f32;
|
let n = t.columns.len().max(1) as f32;
|
||||||
let base: f32 = if t.columns.len() > 6 { 112.0 } else { 136.0 };
|
let base: f32 = if t.columns.len() > 6 { 112.0 } else { 136.0 };
|
||||||
// Narrow, the amount columns give up their slack before the table
|
// Narrow, the amount columns give up their slack before the table
|
||||||
@@ -1388,6 +1524,22 @@ fn render(ui: &mut Ui, t: &Table, shell: &mut Shell<'_>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if save_pdf {
|
||||||
|
let statement = t.statement(&pdf.title, &pdf.period);
|
||||||
|
let meta = pdf.meta.clone();
|
||||||
|
let name = format!("{} — {} — {}", meta.company, pdf.title, pdf.period);
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let ctx = ui.ctx().clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let done = ledger_import::statement_pdf(&statement, &meta)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(|bytes| crate::export::save_and_open(&name, "pdf", &bytes));
|
||||||
|
let _ = tx.send(done);
|
||||||
|
ctx.request_repaint();
|
||||||
|
});
|
||||||
|
st.pdf_job = Some(rx);
|
||||||
|
st.pdf_done = None;
|
||||||
|
}
|
||||||
if let Some(d) = go {
|
if let Some(d) = go {
|
||||||
shell.state.accounts.range = Some((d.start, d.end));
|
shell.state.accounts.range = Some((d.start, d.end));
|
||||||
shell.go(Route::Account(d.account));
|
shell.go(Route::Account(d.account));
|
||||||
@@ -1588,5 +1740,75 @@ fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) {
|
|||||||
if r.vendors.is_empty() {
|
if r.vendors.is_empty() {
|
||||||
t.note("No vendors marked for a 1099 and none paid this year.");
|
t.note("No vendors marked for a 1099 and none paid this year.");
|
||||||
}
|
}
|
||||||
render(ui, &t, shell);
|
let pdf = PdfInfo::new(
|
||||||
|
b,
|
||||||
|
ReportKind::Vendor1099.title(),
|
||||||
|
&format!("Calendar year {year}"),
|
||||||
|
);
|
||||||
|
render(ui, &t, &pdf, shell);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Every table-backed report typesets as a PDF, in its plain, by-month,
|
||||||
|
/// and compared layouts. Set `PDF_DIR` to keep the files for eyes.
|
||||||
|
#[test]
|
||||||
|
fn reports_typeset_as_pdf() {
|
||||||
|
let today = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
|
||||||
|
let dir = std::env::temp_dir().join(format!("ledger-gui-pdf-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
crate::demo::build(&dir, today).unwrap();
|
||||||
|
let b = crate::data::load_once(&crate::data::Source {
|
||||||
|
data_dir: dir.join("books-data"),
|
||||||
|
books_toml: Some(dir.join("wanderbooks.toml")),
|
||||||
|
today: Some(today),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let ytd = |columns, compare| State {
|
||||||
|
range: Range::YearToDate,
|
||||||
|
columns,
|
||||||
|
compare,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (s, e) = Range::YearToDate.dates(&b);
|
||||||
|
let mut tables: Vec<(&str, Table)> = Vec::new();
|
||||||
|
let mut t = pnl(
|
||||||
|
&b,
|
||||||
|
&column_ranges(&ytd(Columns::Total, Compare::None), &b),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
t.add_share_of_income();
|
||||||
|
tables.push(("pnl", t));
|
||||||
|
let cols = column_ranges(&ytd(Columns::Month, Compare::None), &b);
|
||||||
|
tables.push(("pnl-months", pnl(&b, &cols, true)));
|
||||||
|
let mut t = pnl(
|
||||||
|
&b,
|
||||||
|
&column_ranges(&ytd(Columns::Total, Compare::PriorYear), &b),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
t.add_change();
|
||||||
|
tables.push(("pnl-vs-prior-year", t));
|
||||||
|
tables.push(("balance", balance_sheet(&b, &[(e, "Total".into())])));
|
||||||
|
let cols = column_ranges(&ytd(Columns::Total, Compare::None), &b);
|
||||||
|
tables.push(("cashflow", cash_flow_table(&b, &cols, false)));
|
||||||
|
tables.push(("trial", trial_balance(&b, e)));
|
||||||
|
tables.push(("equity", owners_equity(&b, s, e)));
|
||||||
|
tables.push(("tax", tax(&b, s, e)));
|
||||||
|
tables.push(("ar", aging(b.ledger.ar_aging(e), e)));
|
||||||
|
let keep = std::env::var_os("PDF_DIR").map(std::path::PathBuf::from);
|
||||||
|
for (name, t) in tables {
|
||||||
|
let info = PdfInfo::new(&b, name, "Jan 1 – Oct 12, 2026");
|
||||||
|
let bytes =
|
||||||
|
ledger_import::statement_pdf(&t.statement(&info.title, &info.period), &info.meta)
|
||||||
|
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||||
|
assert!(bytes.starts_with(b"%PDF"), "{name}");
|
||||||
|
if let Some(d) = &keep {
|
||||||
|
std::fs::create_dir_all(d).unwrap();
|
||||||
|
std::fs::write(d.join(format!("{name}.pdf")), bytes).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,9 @@ impl Palette {
|
|||||||
.inner_margin(Margin::same(10)),
|
.inner_margin(Margin::same(10)),
|
||||||
)
|
)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.set_width(620.0);
|
// Never wider or taller than the window allows.
|
||||||
|
let screen = ctx.content_rect().size();
|
||||||
|
ui.set_width(620.0_f32.min(screen.x - 48.0).max(200.0));
|
||||||
let edit = egui::TextEdit::singleline(&mut self.query)
|
let edit = egui::TextEdit::singleline(&mut self.query)
|
||||||
.hint_text("Search entries, accounts, vendors, documents, reports…")
|
.hint_text("Search entries, accounts, vendors, documents, reports…")
|
||||||
.font(theme::regular(16.0))
|
.font(theme::regular(16.0))
|
||||||
@@ -89,7 +91,7 @@ impl Palette {
|
|||||||
crate::widgets::rule(ui);
|
crate::widgets::rule(ui);
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.max_height(420.0)
|
.max_height(420.0_f32.min(screen.y - 160.0).max(120.0))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
ui.label(RichText::new("Nothing matches.").color(p.muted));
|
ui.label(RichText::new("Nothing matches.").color(p.muted));
|
||||||
|
|||||||
@@ -67,7 +67,16 @@ pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
|
|||||||
"label": r.label,
|
"label": r.label,
|
||||||
"rule": r.rule_above,
|
"rule": r.rule_above,
|
||||||
"currency": r.currency,
|
"currency": r.currency,
|
||||||
"amounts": r.amounts.iter().map(|a| a.map(|a| {
|
"amounts": r.amounts.iter().enumerate().map(|(i, a)| a.map(|a| {
|
||||||
|
if st.percent_from.is_some_and(|f| i >= f) {
|
||||||
|
let a = a.round_dp(1);
|
||||||
|
let negative = a.is_sign_negative() && !a.is_zero();
|
||||||
|
return serde_json::json!({
|
||||||
|
"digits": format!("{}%", a.abs().normalize()),
|
||||||
|
"negative": negative,
|
||||||
|
"percent": true,
|
||||||
|
});
|
||||||
|
}
|
||||||
let p = amount_parts(a);
|
let p = amount_parts(a);
|
||||||
serde_json::json!({ "digits": p.digits, "negative": p.negative })
|
serde_json::json!({ "digits": p.digits, "negative": p.negative })
|
||||||
})).collect::<Vec<_>>(),
|
})).collect::<Vec<_>>(),
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ pub struct Statement {
|
|||||||
pub columns: Vec<String>,
|
pub columns: Vec<String>,
|
||||||
/// The rows, top to bottom.
|
/// The rows, top to bottom.
|
||||||
pub rows: Vec<Row>,
|
pub rows: Vec<Row>,
|
||||||
|
/// Columns from this index on hold percentages ("12.5%"), not money:
|
||||||
|
/// a comparison's "%" column, a P&L's "% of income".
|
||||||
|
pub percent_from: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row(kind: RowKind, label: impl Into<String>, amounts: Vec<Option<Decimal>>) -> Row {
|
fn row(kind: RowKind, label: impl Into<String>, amounts: Vec<Option<Decimal>>) -> Row {
|
||||||
@@ -131,7 +134,7 @@ fn spacer() -> Row {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Finish a statement: currency signs on sums, rules above them.
|
/// Finish a statement: currency signs on sums, rules above them.
|
||||||
fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Row>) -> Statement {
|
pub fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Row>) -> Statement {
|
||||||
let mut above: Option<RowKind> = None;
|
let mut above: Option<RowKind> = None;
|
||||||
for r in &mut rows {
|
for r in &mut rows {
|
||||||
r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total);
|
r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total);
|
||||||
@@ -149,6 +152,7 @@ fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Row>)
|
|||||||
period,
|
period,
|
||||||
columns: columns.iter().map(|c| c.to_string()).collect(),
|
columns: columns.iter().map(|c| c.to_string()).collect(),
|
||||||
rows,
|
rows,
|
||||||
|
percent_from: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,15 +18,20 @@
|
|||||||
|
|
||||||
#let d = json(bytes(sys.inputs.data))
|
#let d = json(bytes(sys.inputs.data))
|
||||||
#let ncols = d.columns.len()
|
#let ncols = d.columns.len()
|
||||||
|
// Wide statements (by month, with comparisons) turn the page and, past
|
||||||
|
// seven columns, set smaller and tighter so a year of months fits.
|
||||||
|
#let wide = ncols > 4
|
||||||
|
#let dense = ncols > 7
|
||||||
#let ink = luma(25)
|
#let ink = luma(25)
|
||||||
#let quiet = luma(110)
|
#let quiet = luma(110)
|
||||||
#let hair = 0.5pt + luma(160)
|
#let hair = 0.5pt + luma(160)
|
||||||
#let firm = 0.6pt + ink
|
#let firm = 0.6pt + ink
|
||||||
|
|
||||||
#set document(title: d.company + " — " + d.title, author: d.company)
|
#set document(title: d.company + " — " + d.title, author: d.company)
|
||||||
#set text(font: "Inter", size: 9pt, fill: ink, number-width: "tabular")
|
#set text(font: "Inter", size: if dense { 7pt } else { 9pt }, fill: ink, number-width: "tabular")
|
||||||
#set page(
|
#set page(
|
||||||
paper: "us-letter",
|
paper: "us-letter",
|
||||||
|
flipped: wide,
|
||||||
margin: (x: 0.75in, top: 0.75in, bottom: 0.75in),
|
margin: (x: 0.75in, top: 0.75in, bottom: 0.75in),
|
||||||
header: context {
|
header: context {
|
||||||
if counter(page).get().first() > 1 {
|
if counter(page).get().first() > 1 {
|
||||||
@@ -58,7 +63,7 @@
|
|||||||
if a == none { return [] }
|
if a == none { return [] }
|
||||||
let open = if a.negative { [(] } else { [] }
|
let open = if a.negative { [(] } else { [] }
|
||||||
let close = if a.negative { [)] } else { hide[)] }
|
let close = if a.negative { [)] } else { hide[)] }
|
||||||
let sign = if currency [\$] else []
|
let sign = if currency and a.at("percent", default: false) == false [\$] else []
|
||||||
[#open#sign#a.digits#close]
|
[#open#sign#a.digits#close]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +81,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#table(
|
#table(
|
||||||
columns: (1fr,) + (8em,) * ncols,
|
columns: (1fr,) + (if dense { 6.6em } else { 8em },) * ncols,
|
||||||
stroke: none,
|
stroke: none,
|
||||||
inset: (x: 4pt, y: 3.6pt),
|
inset: (x: 4pt, y: 3.6pt),
|
||||||
align: (left + horizon,) + (right + horizon,) * ncols,
|
align: (left + horizon,) + (right + horizon,) * ncols,
|
||||||
@@ -84,7 +89,7 @@
|
|||||||
table.cell(stroke: (top: firm, bottom: hair), []),
|
table.cell(stroke: (top: firm, bottom: hair), []),
|
||||||
..d.columns.map(c => table.cell(
|
..d.columns.map(c => table.cell(
|
||||||
stroke: (top: firm, bottom: hair),
|
stroke: (top: firm, bottom: hair),
|
||||||
text(7.5pt, weight: "bold", tracking: 0.04em, upper(c)),
|
text(if dense { 6pt } else { 7.5pt }, weight: "bold", tracking: 0.04em, upper(c)),
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
..d
|
..d
|
||||||
|
|||||||
Reference in new issue
Block a user