Files
wanderbooks/crates/ledger-gui/src/pages/transactions.rs
T
swaitsandClaude Opus 5.5 ea996f6e7e gui: every screen lays itself out to fit, down to a 360-point tiling slot
Narrow windows overlapped and clipped: the top bar's crumb, date, and
search ran into each other; big numbers wrapped mid-figure; tables
painted columns over each other; the entry and document panes squeezed
their lists to slivers; anything too wide widened the whole page.

- Below 1000 points the sidebar becomes an icon rail (names, attention,
  and integrity checks in tooltips).
- The top bar lays out right to left: search shrinks to its icon, the
  date drops, the crumb truncates.
- Pages are pinned to the window width with narrower margins when tight,
  so one wide thing can't push everything off screen.
- Tiles (KPIs, cash accounts, documents) fit as many columns as the
  width allows, balanced across rows; KPI figures shrink rather than wrap.
- Painted tables shrink their amount columns, clip labels with an
  ellipsis, then scroll sideways rather than overlap.
- List/detail splits show only the detail when both won't fit.
- Reports: the side list becomes a picker; controls wrap by known width.
- Smaller fixes: chart labels thin out, company facts stack, journal
  timestamps yield, transaction filters become a dropdown.
- Min window size drops to 360x400; the screenshot test covers every
  report, a register, a document, and a journal event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-12 20:02:48 -06:00

281 lines
9.1 KiB
Rust

//! 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, cut with an ellipsis before the receipt
// icon and the amount. Cash in reads green with a plus, cash out with
// a minus; entries that don't move cash show their size, muted.
let text_left = left + 44.0;
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)
};
let amount_w = painter
.layout_no_wrap(amount.clone(), theme::semibold(14.0), color)
.size()
.x
.max(64.0);
let max_w = (rect.right() - 10.0 - amount_w - 44.0 - text_left).max(40.0);
widgets::clipped_text(
&painter,
egui::pos2(text_left, rect.center().y - 8.0),
&e.description,
theme::semibold(13.5),
p.text,
max_w,
);
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}"));
}
widgets::clipped_text(
&painter,
egui::pos2(text_left, rect.center().y + 10.0),
&sub,
theme::regular(12.0),
p.muted,
max_w,
);
// Amount and badges.
let right = rect.right() - 10.0;
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() - 10.0 - amount_w - 20.0, rect.center().y),
egui::Align2::CENTER_CENTER,
g,
egui::FontId::proportional(16.0),
c,
);
}
resp
}
fn filters(ui: &mut Ui, b: &Books, st: &mut State) {
let options = [
(Filter::All, "All"),
(Filter::Posted, "Posted"),
(Filter::Drafts, "Drafts"),
(Filter::WithReceipt, "With receipt"),
(Filter::NoReceipt, "No receipt"),
];
// The segmented control needs about 330 points; narrower, a dropdown.
if ui.available_width() >= 340.0 {
widgets::segmented(ui, &mut st.filter, &options);
} else {
let current = options.iter().find(|o| o.0 == st.filter).map(|o| o.1);
egui::ComboBox::from_id_salt("txn_filter")
.selected_text(format!("{} {}", icon::FUNNEL, current.unwrap_or("All")))
.show_ui(ui, |ui| {
for (f, label) in options {
ui.selectable_value(&mut st.filter, f, label);
}
});
}
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;
}
}
}