From bea2ee2dc2a9fa3732d1c22324c61942dbbd17a7 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 12 Oct 2026 20:21:20 -0600 Subject: [PATCH] gui: Save PDF on the general ledger too The general ledger draws its own per-account tables, so it had no PDF button. It now has one: each account a section, a line per posting (date, entry, description, the other side) with debit, credit, and running balance, its beginning balance, and totals with the ending balance, typeset through the same statement template. The PDF plumbing (button, status, background job) is shared by both paths now. The ledger's rows also measure and clip their text and scroll sideways in narrow windows, like every other table. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-gui/src/pages/reports.rs | 424 +++++++++++++++---------- 1 file changed, 249 insertions(+), 175 deletions(-) diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index d1eb35b..a639d4a 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -1272,11 +1272,10 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picke } } -fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) { +/// The "Save PDF" button (busy while one is being made). Also collects a +/// finished job. True when clicked. +fn pdf_button(ui: &mut Ui, st: &mut State) -> bool { let p = pal(ui.ctx()); - let tsv = t.tsv(); - let mut go: Option = 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; @@ -1286,33 +1285,133 @@ fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) { .request_repaint_after(std::time::Duration::from_millis(100)); } } + let busy = st.pdf_job.is_some(); + let label = if busy { + format!("{} Making PDF…", icon::HOURGLASS) + } else { + format!("{} Save PDF", icon::FILE_PDF) + }; + 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() +} + +/// Where the last PDF went (or why it didn't), for a few seconds. +fn pdf_status(ui: &mut Ui, st: &State) { + let p = pal(ui.ctx()); + let Some((done, at)) = &st.pdf_done else { + return; + }; + if ui.input(|i| i.time) - at >= 8.0 { + return; + } + 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)); +} + +/// Typeset `t` in the background, save it to Downloads, and open it. +fn start_pdf(ctx: &egui::Context, st: &mut State, t: &Table, pdf: &PdfInfo) { + 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 = 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; +} + +/// The general ledger as a table for the PDF: each account a section, a +/// line per posting (date, entry, description, the other side), its +/// beginning balance, and totals with the ending balance. +fn gl_table(g: &ledger_core::reports::GeneralLedger) -> Table { + let mut t = Table::new(vec!["Debit".into(), "Credit".into(), "Balance".into()]); + let amt = |v: Decimal| { + if v.is_zero() { + Cell::none() + } else { + Cell::of(v) + } + }; + for a in g + .accounts + .iter() + .filter(|a| !a.postings.is_empty() || !a.beginning_balance.is_zero()) + { + t.section(&format!("{} {}", a.code, a.name)); + t.row( + Kind::Line, + "Beginning balance", + vec![Cell::none(), Cell::none(), Cell::of(a.beginning_balance)], + ); + for post in &a.postings { + t.row( + Kind::Line, + format!( + "{} · #{} · {} · {}", + fmt::date_short(post.date), + post.entry_id, + post.description, + post.split + ), + vec![amt(post.debit), amt(post.credit), Cell::of(post.balance)], + ); + } + t.row( + Kind::Subtotal, + "Totals and ending balance", + vec![ + Cell::of(a.total_debits), + Cell::of(a.total_credits), + Cell::of(a.ending_balance), + ], + ); + } + t +} + +fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let tsv = t.tsv(); + let mut go: Option = None; + let st = &mut shell.state.reports; let mut save_pdf = false; widgets::card(ui, |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; - } + save_pdf = pdf_button(ui, st); ui.add_space(8.0); if ui .add( @@ -1329,31 +1428,7 @@ fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) { 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)); - } - } + pdf_status(ui, st); let n = t.columns.len().max(1) as f32; let base: f32 = if t.columns.len() > 6 { 112.0 } else { 136.0 }; // Narrow, the amount columns give up their slack before the table @@ -1525,20 +1600,7 @@ fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, 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; + start_pdf(ui.ctx(), st, t, pdf); } if let Some(d) = go { shell.state.accounts.range = Some((d.start, d.end)); @@ -1551,6 +1613,21 @@ fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>) let Ok(g) = general_ledger(&b.ledger, s, e) else { return; }; + let st = &mut shell.state.reports; + let mut save_pdf = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + save_pdf = pdf_button(ui, st); + }); + pdf_status(ui, st); + if save_pdf { + let pdf = PdfInfo::new( + b, + ReportKind::GeneralLedger.title(), + &format!("{} – {}", fmt::date(s), fmt::date(e)), + ); + start_pdf(ui.ctx(), st, &gl_table(&g), &pdf); + } + ui.add_space(6.0); let mut go: Option = None; for a in g .accounts @@ -1575,122 +1652,118 @@ fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>) ); }); ui.add_space(6.0); - let w = ui.available_width(); + // Narrow, the description gives way down to 120 points, then + // the table scrolls sideways. let fixed = [86.0, 52.0, 0.0, 110.0, 110.0, 120.0]; - let flex = (w - fixed.iter().sum::()).max(120.0); - let row = |ui: &mut Ui, - vals: [String; 6], - strong: bool, - color: egui::Color32| - -> egui::Response { - let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); - if resp.hovered() && !strong { - ui.painter().rect_filled(r, CornerRadius::same(4), p.hover); - } - let painter = ui.painter_at(r); - let mut x = r.left(); - for (i, v) in vals.iter().enumerate() { - let cw = if i == 2 { flex } else { fixed[i] }; - let font = if strong { - theme::semibold(12.5) - } else { - theme::regular(12.5) - }; - if i >= 3 { - painter.text( - egui::pos2(x + cw - 4.0, r.center().y), - egui::Align2::RIGHT_CENTER, - v, - font, - color, - ); - } else { - let max_chars = ((cw - 8.0) / 6.8) as usize; - let shown: String = if v.chars().count() > max_chars { - v.chars() - .take(max_chars.saturating_sub(1)) - .collect::() - + "…" - } else { - v.clone() - }; - painter.text( - egui::pos2(x + 4.0, r.center().y), - egui::Align2::LEFT_CENTER, - shown, - font, - color, - ); + let w = ui.available_width().max(fixed.iter().sum::() + 120.0); + let flex = w - fixed.iter().sum::(); + widgets::min_width(ui, &format!("gl_{}", a.code), w, |ui| { + let row = |ui: &mut Ui, + vals: [String; 6], + strong: bool, + color: egui::Color32| + -> egui::Response { + let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); + if resp.hovered() && !strong { + ui.painter().rect_filled(r, CornerRadius::same(4), p.hover); } - x += cw; - } - resp - }; - row( - ui, - [ - "Date".into(), - "#".into(), - "Description".into(), - "Debit".into(), - "Credit".into(), - "Balance".into(), - ], - true, - p.muted, - ); - row( - ui, - [ - String::new(), - String::new(), - "Beginning balance".into(), - String::new(), - String::new(), - fmt::accounting(a.beginning_balance), - ], - false, - p.muted, - ); - for post in &a.postings { - let r = row( + let painter = ui.painter_at(r); + let mut x = r.left(); + for (i, v) in vals.iter().enumerate() { + let cw = if i == 2 { flex } else { fixed[i] }; + let font = if strong { + theme::semibold(12.5) + } else { + theme::regular(12.5) + }; + if i >= 3 { + painter.text( + egui::pos2(x + cw - 4.0, r.center().y), + egui::Align2::RIGHT_CENTER, + v, + font, + color, + ); + } else { + widgets::clipped_text( + &painter, + egui::pos2(x + 4.0, r.center().y), + v, + font, + color, + cw - 8.0, + ); + } + x += cw; + } + resp + }; + row( ui, [ - fmt::date_short(post.date), - post.entry_id.to_string(), - format!("{} · {}", post.description, post.split), - if post.debit.is_zero() { - String::new() - } else { - fmt::money(post.debit) - }, - if post.credit.is_zero() { - String::new() - } else { - fmt::money(post.credit) - }, - fmt::accounting(post.balance), + "Date".into(), + "#".into(), + "Description".into(), + "Debit".into(), + "Credit".into(), + "Balance".into(), + ], + true, + p.muted, + ); + row( + ui, + [ + String::new(), + String::new(), + "Beginning balance".into(), + String::new(), + String::new(), + fmt::accounting(a.beginning_balance), ], false, + p.muted, + ); + for post in &a.postings { + let r = row( + ui, + [ + fmt::date_short(post.date), + post.entry_id.to_string(), + format!("{} · {}", post.description, post.split), + if post.debit.is_zero() { + String::new() + } else { + fmt::money(post.debit) + }, + if post.credit.is_zero() { + String::new() + } else { + fmt::money(post.credit) + }, + fmt::accounting(post.balance), + ], + false, + p.text, + ); + if r.clicked() { + go = Some(Route::Entry(post.entry_id)); + } + } + row( + ui, + [ + String::new(), + String::new(), + "Totals and ending balance".into(), + fmt::money(a.total_debits), + fmt::money(a.total_credits), + fmt::accounting(a.ending_balance), + ], + true, p.text, ); - if r.clicked() { - go = Some(Route::Entry(post.entry_id)); - } - } - row( - ui, - [ - String::new(), - String::new(), - "Totals and ending balance".into(), - fmt::money(a.total_debits), - fmt::money(a.total_credits), - fmt::accounting(a.ending_balance), - ], - true, - p.text, - ); + }); }); ui.add_space(12.0); } @@ -1797,6 +1870,7 @@ mod tests { 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))); + tables.push(("gl", gl_table(&general_ledger(&b.ledger, s, e).unwrap()))); 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");