feat: ledger-gui — a read-only, live desktop view of the books

egui/wgpu app for owners and their accountant: Overview, Cash, Profit,
Transactions (with rendered receipts and journal history), Reports
(statements with interval columns, comparisons, drill-down), Accounts,
Documents, Company, Journal; Ctrl+K palette; back/forward; light/dark.
Follows the journal live (~30 ms from write to screen). --demo opens a
deterministic demo company, also used for screenshot tests.

Engine: ledger-store Snapshot (read-only, incremental, tolerant of a
half-written tail); ledger-core analysis (intervals, balance series,
P&L by interval, cash flows); ledger-config extracted from ledger-mcp
with the full company profile and cash_accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-12 16:20:14 -07:00
1 parent b48addd0e0
commit 105fd52694
52 files changed
+14050 -113

No files matched your search

+279
View File
@@ -0,0 +1,279 @@
//! Every entry, searchable and filterable, with the selected one open
//! beside the list: its lines, its receipt rendered, its history.
use super::page;
use crate::app::Shell;
use crate::books::{Books, EntryInfo};
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::{AccountId, EntryStatus};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Filter {
#[default]
All,
Posted,
Drafts,
NoReceipt,
WithReceipt,
}
#[derive(Default)]
pub struct State {
pub query: String,
pub filter: Filter,
pub account: Option<AccountId>,
}
pub fn matches(e: &EntryInfo, st: &State) -> bool {
let f = match st.filter {
Filter::All => true,
Filter::Posted => e.status == EntryStatus::Posted,
Filter::Drafts => e.status == EntryStatus::Draft,
Filter::NoReceipt => e.receipts.is_empty(),
Filter::WithReceipt => !e.receipts.is_empty(),
};
let a = st.account.is_none_or(|a| e.accounts.contains(&a));
let q = st.query.trim().to_lowercase();
let t = q.is_empty() || q.split_whitespace().all(|w| e.haystack.contains(w));
f && a && t
}
pub fn show(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
match selected.and_then(|id| b.entry(id)) {
Some(e) => {
let w = (ui.available_width() * 0.5).max(420.0);
super::split(
ui,
"transactions",
w,
shell,
|ui, shell| list(ui, b, selected, shell),
|ui, shell| super::entry::detail(ui, b, e, shell),
);
}
None => list(ui, b, None, shell),
}
}
fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "transactions", |ui| {
let total = b.entries.len();
widgets::page_header(
ui,
"Transactions",
&format!(
"{} recorded, newest first. Click one to see its lines, its receipt, and its history.",
fmt::count(total, "entry", "entries")
),
);
let st = &mut shell.state.transactions;
let narrow = ui.available_width() < 720.0;
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!(
"{} Search description, account, vendor, amount",
icon::MAGNIFYING_GLASS
))
.desired_width(if narrow { ui.available_width() } else { 340.0 }),
);
if !narrow {
filters(ui, b, st);
}
});
if narrow {
ui.add_space(6.0);
ui.horizontal(|ui| filters(ui, b, st));
}
ui.add_space(12.0);
let rows: Vec<&EntryInfo> = b.entries.iter().filter(|e| matches(e, st)).collect();
if rows.is_empty() {
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::MAGNIFYING_GLASS,
"No matching entries",
"Try a different search or filter.",
)
});
return;
}
widgets::card(ui, |ui| {
let mut last_month: Option<(i32, u32)> = None;
for e in rows {
let m = (
chrono::Datelike::year(&e.date),
chrono::Datelike::month(&e.date),
);
if last_month != Some(m) {
if last_month.is_some() {
ui.add_space(10.0);
}
last_month = Some(m);
ui.label(
RichText::new(fmt::month(e.date))
.font(theme::semibold(12.5))
.color(p.muted),
);
ui.add_space(2.0);
}
if row(ui, b, e, selected == Some(e.id), shell.since_update).clicked() {
shell.go(Route::Entry(e.id));
}
}
});
});
}
fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> egui::Response {
let p = pal(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 50.0), Sense::click());
let fresh = b.fresh.contains(&e.id) && since_update < 8.0;
if on {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.selected);
} else if fresh {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.new_highlight);
} else if resp.hovered() {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.hover);
}
let painter = ui.painter_at(rect);
let left = rect.left() + 10.0;
// Date block.
painter.text(
egui::pos2(left, rect.center().y - 8.0),
egui::Align2::LEFT_CENTER,
e.date.format("%b").to_string().to_uppercase(),
theme::semibold(10.5),
p.faint,
);
painter.text(
egui::pos2(left, rect.center().y + 8.0),
egui::Align2::LEFT_CENTER,
e.date.format("%-d").to_string(),
theme::semibold(16.0),
p.text,
);
// Description and accounts.
let text_left = left + 44.0;
let amount_w = 120.0;
let max_w = (rect.right() - amount_w - 70.0 - text_left).max(60.0);
let desc = painter.layout(e.description.clone(), theme::semibold(13.5), p.text, max_w);
let desc = if desc.rows.len() > 1 {
painter.layout_no_wrap(
truncate(&e.description, (max_w / 7.5) as usize),
theme::semibold(13.5),
p.text,
)
} else {
desc
};
painter.galley(egui::pos2(text_left, rect.center().y - 17.0), desc, p.text);
let accounts: Vec<String> = e.accounts.iter().map(|a| b.account_name(*a)).collect();
let mut sub = format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → "));
if let Some(v) = &e.party {
sub.push_str(&format!(" · {v}"));
}
let sub = truncate(&sub, (max_w / 6.3) as usize);
painter.text(
egui::pos2(text_left, rect.center().y + 10.0),
egui::Align2::LEFT_CENTER,
sub,
theme::regular(12.0),
p.muted,
);
// Amount and badges.
let right = rect.right() - 10.0;
// Cash in reads green with a plus, cash out with a minus; entries that
// don't move cash show their size, muted.
let (amount, color) = if e.cash_effect > rust_decimal::Decimal::ZERO {
(format!("+{}", fmt::money(e.cash_effect)), p.positive)
} else if e.cash_effect < rust_decimal::Decimal::ZERO {
(fmt::money(e.cash_effect), p.text)
} else {
(fmt::money(e.amount), p.muted)
};
painter.text(
egui::pos2(right, rect.center().y - 6.0),
egui::Align2::RIGHT_CENTER,
amount,
theme::semibold(14.0),
color,
);
let mut badges: Vec<(&str, egui::Color32)> = Vec::new();
if e.status == EntryStatus::Draft {
badges.push(("DRAFT", p.warning));
}
if e.reversed_by.is_some() {
badges.push(("REVERSED", p.muted));
}
if e.reversal_of.is_some() {
badges.push(("REVERSAL", p.muted));
}
let mut x = right;
for (text, color) in badges {
let g = painter.layout_no_wrap(text.to_string(), theme::semibold(10.0), color);
x -= g.size().x;
painter.galley(egui::pos2(x, rect.center().y + 6.0), g, color);
x -= 8.0;
}
let clip = if !e.receipts.is_empty() {
Some((icon::PAPERCLIP, p.muted))
} else if e.missing_receipt {
Some((icon::WARNING_CIRCLE, p.negative))
} else {
None
};
if let Some((g, c)) = clip {
painter.text(
egui::pos2(rect.right() - amount_w - 24.0, rect.center().y),
egui::Align2::CENTER_CENTER,
g,
egui::FontId::proportional(16.0),
c,
);
}
resp
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
s.to_string()
} else {
let t: String = s.chars().take(n.saturating_sub(1)).collect();
format!("{t}…")
}
}
fn filters(ui: &mut Ui, b: &Books, st: &mut State) {
widgets::segmented(
ui,
&mut st.filter,
&[
(Filter::All, "All"),
(Filter::Posted, "Posted"),
(Filter::Drafts, "Drafts"),
(Filter::WithReceipt, "With receipt"),
(Filter::NoReceipt, "No receipt"),
],
);
if let Some(a) = st.account {
ui.add_space(6.0);
let label = format!("{} {} {}", icon::FUNNEL, b.account_label(a), icon::X);
if ui
.button(label)
.on_hover_text("Clear the account filter")
.clicked()
{
st.account = None;
}
}
}