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 <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:21:20 -06:00
1 parent a6566e801f
commit bea2ee2dc2
1 file changed
+138 -64
+138 -64
View File
@@ -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<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;
@@ -1286,17 +1285,13 @@ fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) {
.request_repaint_after(std::time::Duration::from_millis(100));
}
}
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(
ui.add_enabled(
!busy,
egui::Button::new(
RichText::new(label)
@@ -1310,9 +1305,113 @@ fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) {
crate::export::downloads_dir().display()
))
.clicked()
{
save_pdf = true;
}
/// 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<Drill> = 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| {
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<Route> = None;
for a in g
.accounts
@@ -1575,9 +1652,12 @@ 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::<f32>()).max(120.0);
let w = ui.available_width().max(fixed.iter().sum::<f32>() + 120.0);
let flex = w - fixed.iter().sum::<f32>();
widgets::min_width(ui, &format!("gl_{}", a.code), w, |ui| {
let row = |ui: &mut Ui,
vals: [String; 6],
strong: bool,
@@ -1605,21 +1685,13 @@ fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>)
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::<String>()
+ "…"
} else {
v.clone()
};
painter.text(
widgets::clipped_text(
&painter,
egui::pos2(x + 4.0, r.center().y),
egui::Align2::LEFT_CENTER,
shown,
v,
font,
color,
cw - 8.0,
);
}
x += cw;
@@ -1692,6 +1764,7 @@ fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>)
p.text,
);
});
});
ui.add_space(12.0);
}
if let Some(r) = go {
@@ -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");