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
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-12 20:02:48 -06:00
1 parent 3b274ed853
commit 6fb594ed28
16 files changed
+904 -463

No files matched your search

+166 -140
View File
@@ -80,12 +80,27 @@ pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
theme::regular(13.0),
p.muted,
);
painter.text(
let bal_text = fmt::accounting(bal);
let bal_w = painter
.layout_no_wrap(bal_text.clone(), theme::semibold(13.5), p.text)
.size()
.x;
// Tags sit in their own column when there's room for
// it; narrower, they move to the row's tooltip.
let tags_x = 340.0;
let tags_room = r.width() - tags_x - bal_w - 24.0;
let name_room = if tags_room >= 120.0 {
tags_x - 64.0 - 12.0
} else {
r.width() - 64.0 - bal_w - 24.0
};
widgets::clipped_text(
&painter,
r.left_center() + vec2(64.0, 0.0),
egui::Align2::LEFT_CENTER,
&a.name,
theme::regular(13.5),
p.text,
name_room,
);
let mut tags = Vec::new();
if b.cash_accounts.contains(&a.id) {
@@ -103,20 +118,29 @@ pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
if a.placeholder {
tags.push("header".into());
}
painter.text(
r.left_center() + vec2(340.0, 0.0),
egui::Align2::LEFT_CENTER,
tags.join(" · "),
theme::regular(12.0),
p.faint,
);
let tags = tags.join(" · ");
if tags_room >= 120.0 {
widgets::clipped_text(
&painter,
r.left_center() + vec2(tags_x, 0.0),
&tags,
theme::regular(12.0),
p.faint,
tags_room,
);
}
painter.text(
r.right_center() - vec2(8.0, 0.0),
egui::Align2::RIGHT_CENTER,
fmt::accounting(bal),
bal_text,
theme::semibold(13.5),
p.text,
);
let resp = if tags_room < 120.0 && !tags.is_empty() {
resp.on_hover_text(format!("{} {} · {tags}", a.code, a.name))
} else {
resp
};
if resp.clicked() {
go = Some(a.id);
}
@@ -168,7 +192,7 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
);
ui.add_space(12.0);
}
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!(
"{} {} – {}",
@@ -206,146 +230,148 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
};
let mut go = None;
widgets::card(ui, |ui| {
let w = ui.available_width();
let amt = 120.0;
let header = |ui: &mut Ui, vals: [&str; 5]| {
let (r, _) = ui.allocate_exact_size(vec2(w, 26.0), Sense::hover());
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
vals[0],
theme::semibold(12.0),
p.muted,
);
ui.painter().text(
r.left_center() + vec2(90.0, 0.0),
egui::Align2::LEFT_CENTER,
vals[1],
theme::semibold(12.0),
p.muted,
);
for (i, v) in vals[2..].iter().enumerate() {
let amt = widgets::amount_col_w(ui.available_width(), 3, 90.0 + 120.0, 120.0);
let w = ui.available_width().max(90.0 + 120.0 + amt * 3.0);
widgets::min_width(ui, "register", w, |ui| {
let header = |ui: &mut Ui, vals: [&str; 5]| {
let (r, _) = ui.allocate_exact_size(vec2(w, 26.0), Sense::hover());
ui.painter().text(
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*v,
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
vals[0],
theme::semibold(12.0),
p.muted,
);
}
};
header(ui, ["Date", "Description", "Debit", "Credit", "Balance"]);
widgets::rule(ui);
let line = |ui: &mut Ui,
date: String,
desc: String,
sub: String,
d: Decimal,
c: Decimal,
bal: Decimal,
strong: bool|
-> egui::Response {
let (r, resp) = ui.allocate_exact_size(
vec2(w, if sub.is_empty() { 30.0 } else { 42.0 }),
Sense::click(),
);
if resp.hovered() && !strong {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
let painter = ui.painter_at(r);
let font = if strong {
theme::semibold(13.0)
} else {
theme::regular(13.0)
};
let y = if sub.is_empty() {
r.center().y
} else {
r.center().y - 7.0
};
painter.text(
egui::pos2(r.left() + 4.0, y),
egui::Align2::LEFT_CENTER,
date,
theme::regular(12.5),
p.muted,
);
// Leave room for the debit, credit, and balance columns.
let text_w = r.width() - 90.0 - amt * 3.0 - 12.0;
widgets::clipped_text(
&painter,
egui::pos2(r.left() + 90.0, y),
&desc,
font.clone(),
p.text,
text_w,
);
if !sub.is_empty() {
widgets::clipped_text(
&painter,
egui::pos2(r.left() + 90.0, r.center().y + 9.0),
&sub,
theme::regular(11.5),
p.faint,
text_w,
ui.painter().text(
r.left_center() + vec2(90.0, 0.0),
egui::Align2::LEFT_CENTER,
vals[1],
theme::semibold(12.0),
p.muted,
);
}
for (i, v) in [d, c].iter().enumerate() {
if !v.is_zero() {
painter.text(
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, y),
for (i, v) in vals[2..].iter().enumerate() {
ui.painter().text(
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
fmt::money(*v),
font.clone(),
p.text,
*v,
theme::semibold(12.0),
p.muted,
);
}
}
painter.text(
egui::pos2(r.right() - 4.0, y),
egui::Align2::RIGHT_CENTER,
fmt::accounting(bal),
font,
p.text,
);
resp
};
line(
ui,
fmt::date_short(s),
"Beginning balance".into(),
String::new(),
Decimal::ZERO,
Decimal::ZERO,
acct.beginning_balance,
true,
);
for post in acct.postings.iter().rev() {
let r = line(
};
header(ui, ["Date", "Description", "Debit", "Credit", "Balance"]);
widgets::rule(ui);
let line = |ui: &mut Ui,
date: String,
desc: String,
sub: String,
d: Decimal,
c: Decimal,
bal: Decimal,
strong: bool|
-> egui::Response {
let (r, resp) = ui.allocate_exact_size(
vec2(w, if sub.is_empty() { 30.0 } else { 42.0 }),
Sense::click(),
);
if resp.hovered() && !strong {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
let painter = ui.painter_at(r);
let font = if strong {
theme::semibold(13.0)
} else {
theme::regular(13.0)
};
let y = if sub.is_empty() {
r.center().y
} else {
r.center().y - 7.0
};
painter.text(
egui::pos2(r.left() + 4.0, y),
egui::Align2::LEFT_CENTER,
date,
theme::regular(12.5),
p.muted,
);
// Leave room for the debit, credit, and balance columns.
let text_w = r.width() - 90.0 - amt * 3.0 - 12.0;
widgets::clipped_text(
&painter,
egui::pos2(r.left() + 90.0, y),
&desc,
font.clone(),
p.text,
text_w,
);
if !sub.is_empty() {
widgets::clipped_text(
&painter,
egui::pos2(r.left() + 90.0, r.center().y + 9.0),
&sub,
theme::regular(11.5),
p.faint,
text_w,
);
}
for (i, v) in [d, c].iter().enumerate() {
if !v.is_zero() {
painter.text(
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, y),
egui::Align2::RIGHT_CENTER,
fmt::money(*v),
font.clone(),
p.text,
);
}
}
painter.text(
egui::pos2(r.right() - 4.0, y),
egui::Align2::RIGHT_CENTER,
fmt::accounting(bal),
font,
p.text,
);
resp
};
line(
ui,
fmt::date_short(post.date),
post.description.clone(),
format!("#{} · {}", post.entry_id, post.split),
post.debit,
post.credit,
post.balance,
false,
fmt::date_short(s),
"Beginning balance".into(),
String::new(),
Decimal::ZERO,
Decimal::ZERO,
acct.beginning_balance,
true,
);
if r.clicked() {
go = Some(post.entry_id);
for post in acct.postings.iter().rev() {
let r = line(
ui,
fmt::date_short(post.date),
post.description.clone(),
format!("#{} · {}", post.entry_id, post.split),
post.debit,
post.credit,
post.balance,
false,
);
if r.clicked() {
go = Some(post.entry_id);
}
}
}
widgets::rule(ui);
line(
ui,
fmt::date_short(e),
"Ending balance".into(),
String::new(),
acct.total_debits,
acct.total_credits,
acct.ending_balance,
true,
);
widgets::rule(ui);
line(
ui,
fmt::date_short(e),
"Ending balance".into(),
String::new(),
acct.total_debits,
acct.total_credits,
acct.ending_balance,
true,
);
});
});
if let Some(entry) = go {
shell.go(Route::Entry(entry));
+2
View File
@@ -56,7 +56,9 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
// One tile per cash and card account.
let mut tiles: Vec<(u64, bool)> = b.cash_accounts.iter().map(|a| (*a, false)).collect();
tiles.extend(b.card_accounts.iter().map(|a| (*a, true)));
// Up to four across, fewer when the window is narrow.
let n = tiles.len().clamp(1, 4);
let n = widgets::fit_columns(ui.available_width(), 200.0, 16.0, n);
for row in tiles.chunks(n) {
columns(ui, 0.0, &vec![1.0; n], |i, ui| {
let Some((id, is_card)) = row.get(i) else {
+17 -9
View File
@@ -17,8 +17,16 @@ use rust_decimal::Decimal;
fn kv(ui: &mut Ui, k: &str, v: &str) {
let p = pal(ui.ctx());
// Narrow, the label goes above its value.
if ui.available_width() < 360.0 {
ui.label(RichText::new(k).font(theme::regular(12.0)).color(p.muted));
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(13.0))).wrap());
ui.add_space(4.0);
return;
}
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(180.0, 20.0), Sense::hover());
let kw = (ui.available_width() * 0.4).min(180.0);
let (r, _) = ui.allocate_exact_size(vec2(kw, 20.0), Sense::hover());
ui.painter().text(
r.left_center(),
egui::Align2::LEFT_CENTER,
@@ -79,7 +87,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
.all()
.filter(|a| a.account_type == ledger_core::AccountType::Equity)
{
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!("{} {}", a.code, a.name))
.font(theme::regular(13.0)),
@@ -101,7 +109,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
for m in &c.members {
let cap = b.ledger.chart().find_by_code(&m.capital_account);
let bal = cap.map(|a| b.balance_on(a.id, b.today)).unwrap_or_default();
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&m.name).font(theme::semibold(13.5)));
widgets::pill(
ui,
@@ -141,7 +149,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
ui.label(RichText::new("No periods yet. Closing a period locks it and moves its earnings into equity.").color(p.muted));
}
for per in periods {
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
let (g, color) = match per.status {
PeriodStatus::Closed => (icon::LOCK, p.muted),
PeriodStatus::Open => (icon::LOCK_OPEN, p.warning),
@@ -217,7 +225,7 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
} else {
(acc / dep).try_into().unwrap_or(0.0)
};
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
if widgets::link(
ui,
RichText::new(&a.description).font(theme::semibold(13.5)),
@@ -303,7 +311,7 @@ fn schedules(ui: &mut Ui, b: &Books) {
((total - remaining) / total).try_into().unwrap_or(0.0)
};
let due = s.due(b.today);
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&s.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!(
@@ -361,7 +369,7 @@ fn recurring(ui: &mut Ui, b: &Books) {
.filter(|d| r.end.is_none_or(|e| *d <= e))
})
.find(|d| !r.generated.contains(d));
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&r.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!(
@@ -424,7 +432,7 @@ fn parties(ui: &mut Ui, b: &Books) {
.find(|x| x.vendor_id == v.id)
.map(|x| x.paid)
.unwrap_or_default();
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&v.name).font(theme::semibold(13.0)));
if let Some(f) = v.form_1099 {
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
@@ -470,7 +478,7 @@ fn parties(ui: &mut Ui, b: &Books) {
.filter(|i| i.customer_id == c.id)
.map(|i| i.balance())
.sum();
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&c.name).font(theme::semibold(13.0)));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
+8 -6
View File
@@ -57,7 +57,7 @@ fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!("{} Search file names", icon::MAGNIFYING_GLASS))
.desired_width(260.0),
.desired_width(ui.available_width().min(260.0)),
);
if ui.selectable_label(st.category.is_none(), "All").clicked() {
st.category = None;
@@ -94,10 +94,10 @@ fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
widgets::card(ui, |ui| widgets::empty(ui, icon::FILES, "No documents", ""));
return;
}
let tile_w = 200.0;
let per_row = ((ui.available_width() + 16.0) / (tile_w + 16.0))
.floor()
.max(1.0) as usize;
// As many 200-point tiles as fit, stretched to fill the row.
let per_row = widgets::fit_columns(ui.available_width(), 200.0, 16.0, usize::MAX);
let tile_w =
((ui.available_width() - 16.0 * (per_row as f32 - 1.0)) / per_row as f32).min(280.0);
let mut go = None;
for row in docs.chunks(per_row) {
ui.horizontal_top(|ui| {
@@ -277,7 +277,9 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
format!("#{} {} ({})", x.id, x.description, fmt::dollars(x.amount))
})
.unwrap_or_else(|| format!("#{e}"));
if widgets::link(ui, RichText::new(label).color(p.accent)).clicked() {
if widgets::link_truncated(ui, RichText::new(label).color(p.accent))
.clicked()
{
shell.go(Route::Entry(e));
}
ui.end_row();
+4 -4
View File
@@ -110,14 +110,14 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
ui.end_row();
if let Some(c) = &created {
k(ui, "Recorded");
ui.label(c);
ui.add(egui::Label::new(c).wrap());
ui.end_row();
}
if e.status == EntryStatus::Posted
&& let Some(t) = &posted
{
k(ui, "Posted");
ui.label(t);
ui.add(egui::Label::new(t).wrap());
ui.end_row();
}
});
@@ -126,7 +126,7 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
// Lines.
section(ui, "Lines");
let total_w = ui.available_width();
let amt_w = 104.0;
let amt_w = ((total_w - 26.0) * 0.25).clamp(64.0, 104.0);
let class_names: std::collections::HashMap<u32, String> = b
.ledger
.list_classes()
@@ -159,7 +159,7 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
ui.allocate_ui(vec2(w, 22.0), |ui| {
ui.set_width(w);
ui.horizontal(|ui| {
let r = widgets::link(
let r = widgets::link_truncated(
ui,
RichText::new(b.account_label(line.account))
.font(theme::regular(13.5)),
+20 -10
View File
@@ -343,11 +343,11 @@ fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
),
);
let st = &mut shell.state.journal;
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!("{} Filter events", icon::MAGNIFYING_GLASS))
.desired_width(320.0),
.desired_width(ui.available_width().min(320.0)),
);
ui.checkbox(&mut st.hide_steps, "Hide entry-building steps");
});
@@ -401,20 +401,30 @@ fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
egui::FontId::proportional(15.0),
p.muted,
);
// The timestamp yields to the event when it's tight.
let ts = painter.layout_no_wrap(
fmt::timestamp(&line.ts),
theme::regular(12.0),
p.faint,
);
let ts_w = if rect.width() - 80.0 - ts.size().x - 24.0 >= 200.0 {
let w = ts.size().x;
painter.galley(
egui::pos2(rect.right() - 8.0 - w, y - ts.size().y / 2.0),
ts,
p.faint,
);
w + 24.0
} else {
8.0
};
widgets::clipped_text(
&painter,
egui::pos2(rect.left() + 80.0, y),
&ev.text,
theme::regular(13.0),
p.text,
rect.width() - 80.0 - 200.0,
);
painter.text(
egui::pos2(rect.right() - 8.0, y),
egui::Align2::RIGHT_CENTER,
fmt::timestamp(&line.ts),
theme::regular(12.0),
p.faint,
rect.width() - 80.0 - ts_w,
);
if resp.clicked() {
shell.go(Route::JournalEvent(line.seq));
+53 -5
View File
@@ -18,19 +18,25 @@ use egui::{Margin, Ui};
/// Standard page body: scrollable, padded.
pub fn page(ui: &mut Ui, id: &str, add: impl FnOnce(&mut Ui)) {
// Pinned to the window: something too wide is clipped (and found in
// review) rather than widening the page past its edge, which would
// carry over to the next frame and push everything else off screen.
let width = ui.available_width();
let side: i8 = if width < 560.0 { 16 } else { 32 };
egui::ScrollArea::vertical()
.id_salt(id)
.max_width(width)
.auto_shrink(false)
.show(ui, |ui| {
egui::Frame::new()
.inner_margin(Margin {
left: 32,
right: 32,
left: side,
right: side,
top: 24,
bottom: 40,
})
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_width((width - 2.0 * side as f32).max(0.0));
add(ui)
});
});
@@ -89,8 +95,37 @@ pub fn columns(ui: &mut Ui, min_width: f32, weights: &[f32], mut add: impl FnMut
ui.add_space(gap);
}
const SPLIT_MIN_LIST: f32 = 380.0;
const SPLIT_MIN_DETAIL: f32 = 360.0;
/// Tiles in rows: as many columns as fit at `min_width` each (up to
/// `max`), evened out across the rows, the tiles stretched to fill. `add(i, ui)` fills tile `i`.
pub fn grid(
ui: &mut Ui,
n: usize,
min_width: f32,
max: usize,
mut add: impl FnMut(usize, &mut Ui),
) {
let fit = crate::widgets::fit_columns(ui.available_width(), min_width, 16.0, max);
// Balanced rows: six tiles that fit five across go three and three,
// not five and one.
let rows = n.max(1).div_ceil(fit);
let per_row = n.max(1).div_ceil(rows);
let idx: Vec<usize> = (0..n).collect();
for row in idx.chunks(per_row) {
columns(ui, 0.0, &vec![1.0; per_row], |i, ui| {
if let Some(&t) = row.get(i) {
add(t, ui);
}
});
}
}
/// A list on the left and a detail pane on the right, split by a
/// draggable divider. The split is remembered per `id`.
/// draggable divider. The split is remembered per `id`. Too narrow for
/// both, the detail takes the whole width (its close button goes back to
/// the list).
pub fn split<T: ?Sized>(
ui: &mut Ui,
id: &str,
@@ -101,12 +136,25 @@ pub fn split<T: ?Sized>(
) {
let p = crate::theme::pal(ui.ctx());
let rect = ui.available_rect_before_wrap();
if rect.width() < SPLIT_MIN_LIST + SPLIT_MIN_DETAIL {
let inner = rect.shrink2(egui::vec2(16.0, 16.0));
ui.painter().rect_filled(rect, 0.0, p.surface);
let mut r = ui.new_child(
egui::UiBuilder::new()
.max_rect(inner)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
r.set_clip_rect(rect);
right(&mut r, state);
ui.allocate_rect(rect, egui::Sense::hover());
return;
}
let key = egui::Id::new(("split", id));
let mut detail_w: f32 = ui
.ctx()
.data_mut(|d| d.get_persisted(key))
.unwrap_or(default_detail);
detail_w = detail_w.clamp(360.0, (rect.width() - 380.0).max(360.0));
detail_w = detail_w.clamp(SPLIT_MIN_DETAIL, rect.width() - SPLIT_MIN_LIST);
let divider_x = rect.right() - detail_w;
let left_rect = egui::Rect::from_min_max(rect.min, egui::pos2(divider_x, rect.bottom()));
let right_rect = egui::Rect::from_min_max(egui::pos2(divider_x + 1.0, rect.top()), rect.max);
+16 -21
View File
@@ -223,27 +223,22 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
Route::Cash,
),
];
let n = if ui.available_width() > 1180.0 { 6 } else { 3 };
for row in tiles.chunks(n) {
let weights = vec![1.0; n];
columns(ui, 0.0, &weights, |i, ui| {
if let Some((tile, route)) = row.get(i) {
let r = widgets::kpi(
ui,
Kpi {
label: tile.label,
value: tile.value.clone(),
sub: tile.sub.clone(),
spark: tile.spark,
how: tile.how,
},
);
if r.interact(egui::Sense::click()).clicked() {
shell.go(route.clone());
}
}
});
}
super::grid(ui, tiles.len(), 150.0, 6, |i, ui| {
let (tile, route) = &tiles[i];
let r = widgets::kpi(
ui,
Kpi {
label: tile.label,
value: tile.value.clone(),
sub: tile.sub.clone(),
spark: tile.spark,
how: tile.how,
},
);
if r.interact(egui::Sense::click()).clicked() {
shell.go(route.clone());
}
});
}
/// `change` picks from the light palette; map to the current one.
+108 -102
View File
@@ -90,68 +90,70 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
}
ui.add_space(10.0);
// The same numbers as a table, with the margin.
let w = ui.available_width();
let cw = 130.0;
let header = |ui: &mut Ui, cells: [&str; 5]| {
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
cells[0],
theme::semibold(12.0),
p.muted,
);
for (i, c) in cells[1..].iter().enumerate() {
let cw = widgets::amount_col_w(ui.available_width(), 4, 100.0, 130.0);
let w = ui.available_width().max(100.0 + cw * 4.0);
widgets::min_width(ui, "by_month_table", w, |ui| {
let header = |ui: &mut Ui, cells: [&str; 5]| {
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
ui.painter().text(
egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*c,
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
cells[0],
theme::semibold(12.0),
p.muted,
);
}
};
header(ui, ["Month", "Income", "Spending", "Net", "Margin"]);
for m in months.iter().rev() {
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
}
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
fmt::month(m.start),
theme::regular(13.0),
p.text,
);
let vals = [
fmt::money(m.revenue),
fmt::money(m.expenses),
fmt::accounting(m.net()),
pct(m.net(), m.revenue)
.map(|x| format!("{x}%"))
.unwrap_or_else(|| "—".into()),
];
for (i, v) in vals.iter().enumerate() {
let color = if i == 2 && m.net() < Decimal::ZERO {
p.negative
} else {
p.text
};
for (i, c) in cells[1..].iter().enumerate() {
ui.painter().text(
egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*c,
theme::semibold(12.0),
p.muted,
);
}
};
header(ui, ["Month", "Income", "Spending", "Net", "Margin"]);
for m in months.iter().rev() {
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
}
ui.painter().text(
egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
v,
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
fmt::month(m.start),
theme::regular(13.0),
color,
p.text,
);
let vals = [
fmt::money(m.revenue),
fmt::money(m.expenses),
fmt::accounting(m.net()),
pct(m.net(), m.revenue)
.map(|x| format!("{x}%"))
.unwrap_or_else(|| "—".into()),
];
for (i, v) in vals.iter().enumerate() {
let color = if i == 2 && m.net() < Decimal::ZERO {
p.negative
} else {
p.text
};
ui.painter().text(
egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
v,
theme::regular(13.0),
color,
);
}
if resp.clicked() {
shell.state.reports.range =
crate::pages::reports::Range::Custom(m.start, m.end);
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
}
}
if resp.clicked() {
shell.state.reports.range =
crate::pages::reports::Range::Custom(m.start, m.end);
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
}
}
});
},
);
ui.add_space(16.0);
@@ -210,66 +212,70 @@ fn spending_changes(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
widgets::empty(ui, icon::RECEIPT, "No spending yet", "");
return;
}
let w = ui.available_width();
let cw = 130.0;
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
for (i, c) in ["This month", "Last month", "3-month avg"]
.iter()
.enumerate()
{
ui.painter().text(
egui::pos2(r.right() - cw * (2 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*c,
theme::semibold(12.0),
p.muted,
);
}
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
"Account",
theme::semibold(12.0),
p.muted,
);
for (code, (name, now, last, avg)) in &rows {
let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
let label_min = 180.0;
let cw = widgets::amount_col_w(ui.available_width(), 3, label_min, 130.0);
let w = ui.available_width().max(label_min + cw * 3.0);
widgets::min_width(ui, "spending_table", w, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
for (i, c) in ["This month", "Last month", "3-month avg"]
.iter()
.enumerate()
{
ui.painter().text(
egui::pos2(r.right() - cw * (2 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*c,
theme::semibold(12.0),
p.muted,
);
}
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
format!("{code} {name}"),
theme::regular(13.0),
p.text,
"Account",
theme::semibold(12.0),
p.muted,
);
// Flag a category already running well above its average.
let hot = *avg > Decimal::ZERO && *now > *avg * Decimal::new(125, 2);
let vals = [(*now, hot), (*last, false), (*avg, false)];
for (i, (v, flag)) in vals.iter().enumerate() {
let x = r.right() - cw * (2 - i) as f32 - 4.0;
ui.painter().text(
egui::pos2(x, r.center().y),
egui::Align2::RIGHT_CENTER,
fmt::money(v.round_dp(2)),
for (code, (name, now, last, avg)) in &rows {
let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
}
widgets::clipped_text(
ui.painter(),
r.left_center() + vec2(4.0, 0.0),
&format!("{code} {name}"),
theme::regular(13.0),
if *flag { p.warning } else { p.text },
p.text,
w - cw * 3.0 - 8.0,
);
if *flag {
// Flag a category already running well above its average.
let hot = *avg > Decimal::ZERO && *now > *avg * Decimal::new(125, 2);
let vals = [(*now, hot), (*last, false), (*avg, false)];
for (i, (v, flag)) in vals.iter().enumerate() {
let x = r.right() - cw * (2 - i) as f32 - 4.0;
ui.painter().text(
egui::pos2(x - 86.0, r.center().y),
egui::pos2(x, r.center().y),
egui::Align2::RIGHT_CENTER,
icon::ARROW_UP_RIGHT,
egui::FontId::proportional(13.0),
p.warning,
fmt::money(v.round_dp(2)),
theme::regular(13.0),
if *flag { p.warning } else { p.text },
);
if *flag {
ui.painter().text(
egui::pos2(x - 86.0, r.center().y),
egui::Align2::RIGHT_CENTER,
icon::ARROW_UP_RIGHT,
egui::FontId::proportional(13.0),
p.warning,
);
}
}
if resp.clicked() {
go = b.ledger.chart().find_by_code(code).map(|a| a.id);
}
}
if resp.clicked() {
go = b.ledger.chart().find_by_code(code).map(|a| a.id);
}
}
});
},
);
if let Some(id) = go {
+102 -41
View File
@@ -859,9 +859,43 @@ fn aging(rows: Vec<ledger_core::AgingRow>, as_of: NaiveDate) -> Table {
// ----------------------------------------------------------------------
// The page.
/// The reports, grouped as the side list shows them.
const GROUPS: [(&str, &[ReportKind]); 3] = [
(
"Statements",
&[
ReportKind::ProfitAndLoss,
ReportKind::BalanceSheet,
ReportKind::CashFlow,
ReportKind::MembersEquity,
],
),
(
"Ledgers",
&[ReportKind::TrialBalance, ReportKind::GeneralLedger],
),
(
"Tax and aging",
&[
ReportKind::TaxSummary,
ReportKind::Vendor1099,
ReportKind::ArAging,
ReportKind::ApAging,
],
),
];
/// Narrower than this, the side list of reports becomes a picker above
/// the report.
const SIDE_LIST_MIN: f32 = 760.0;
pub fn show(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let rect = ui.available_rect_before_wrap();
if rect.width() < SIDE_LIST_MIN {
super::page(ui, "report", |ui| report(ui, b, kind, shell, true));
return;
}
let nav_w = 210.0;
let nav_rect = egui::Rect::from_min_size(rect.min, vec2(nav_w, rect.height()));
ui.painter().line_segment(
@@ -875,31 +909,7 @@ pub fn show(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) {
.color(p.faint),
);
nav.add_space(4.0);
let groups: [(&str, &[ReportKind]); 3] = [
(
"Statements",
&[
ReportKind::ProfitAndLoss,
ReportKind::BalanceSheet,
ReportKind::CashFlow,
ReportKind::MembersEquity,
],
),
(
"Ledgers",
&[ReportKind::TrialBalance, ReportKind::GeneralLedger],
),
(
"Tax and aging",
&[
ReportKind::TaxSummary,
ReportKind::Vendor1099,
ReportKind::ArAging,
ReportKind::ApAging,
],
),
];
for (g, kinds) in groups {
for (g, kinds) in GROUPS {
nav.add_space(10.0);
nav.label(RichText::new(g).font(theme::regular(12.0)).color(p.muted));
nav.add_space(2.0);
@@ -937,11 +947,11 @@ pub fn show(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) {
let body = egui::Rect::from_min_max(egui::pos2(nav_rect.right() + 1.0, rect.top()), rect.max);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body));
child.set_clip_rect(body);
super::page(&mut child, "report", |ui| report(ui, b, kind, shell));
super::page(&mut child, "report", |ui| report(ui, b, kind, shell, false));
ui.allocate_rect(rect, Sense::hover());
}
fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) {
fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picker: bool) {
let p = pal(ui.ctx());
let point_in_time = matches!(
kind,
@@ -961,8 +971,43 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) {
);
let st = &mut shell.state.reports;
// Controls.
// Controls. Each starts a new row when it won't fit on this one, by
// its known width: a control that overflows would widen the page.
let mut pick = None;
let row_w = ui.available_width();
let mut used = 0.0;
let mut room = |ui: &mut Ui, w: f32| {
if used > 0.0 && used + 8.0 + w > row_w {
ui.end_row();
used = 0.0;
} else if used > 0.0 {
ui.add_space(8.0);
used += 8.0;
}
used += w;
};
ui.horizontal_wrapped(|ui| {
if picker {
room(ui, 214.0);
egui::ComboBox::from_id_salt("report_kind")
.selected_text(RichText::new(format!(
"{} {}",
icon::FILE_TEXT,
kind.title()
)))
.width(190.0)
.show_ui(ui, |ui| {
for (g, kinds) in GROUPS {
ui.label(RichText::new(g).font(theme::regular(12.0)).color(p.muted));
for k in kinds {
if ui.selectable_label(*k == kind, k.title()).clicked() {
pick = Some(*k);
}
}
}
});
}
room(ui, 194.0);
egui::ComboBox::from_id_salt("range")
.selected_text(RichText::new(format!(
"{} {}",
@@ -983,6 +1028,7 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) {
}
});
if let Range::Custom(s, e) = st.range {
room(ui, 250.0);
let (mut js, mut je) = (to_jiff(s), to_jiff(e));
ui.add(
egui_extras::DatePickerButton::new(&mut js)
@@ -999,7 +1045,7 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) {
st.range = Range::Custom(s.min(e), e.max(s));
}
if splittable {
ui.add_space(8.0);
room(ui, 270.0);
widgets::segmented(
ui,
&mut st.columns,
@@ -1012,7 +1058,7 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) {
);
}
if comparable && (st.columns == Columns::Total || !splittable) {
ui.add_space(8.0);
room(ui, 160.0);
egui::ComboBox::from_id_salt("compare")
.selected_text(match st.compare {
Compare::None => "No comparison",
@@ -1146,6 +1192,9 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) {
if let Some(t) = table {
render(ui, &t, shell);
}
if let Some(k) = pick {
shell.go(Route::Reports(k));
}
}
fn render(ui: &mut Ui, t: &Table, shell: &mut Shell<'_>) {
@@ -1169,14 +1218,18 @@ fn render(ui: &mut Ui, t: &Table, shell: &mut Shell<'_>) {
ui.ctx().copy_text(tsv.clone());
}
});
let col_w: f32 = if t.columns.len() > 6 { 112.0 } else { 136.0 };
let cols_w = col_w * t.columns.len() as f32;
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
// scrolls sideways.
let col_w = ((ui.available_width() - 180.0) / n).clamp(100.0, base);
let cols_w = col_w * n;
egui::ScrollArea::horizontal()
.id_salt("report_table")
.show(ui, |ui| {
// Labels get what's left, but never so much that the eye loses
// the line between a label and its amount.
let label_w = (ui.available_width() - cols_w).clamp(300.0, 480.0);
let label_w = (ui.available_width() - cols_w).clamp(180.0, 480.0);
let width = label_w + cols_w;
ui.set_min_width(width);
let (hr, _) = ui.allocate_exact_size(vec2(width, 30.0), Sense::hover());
@@ -1223,21 +1276,29 @@ fn render(ui: &mut Ui, t: &Table, shell: &mut Shell<'_>) {
_ if !r.detail.is_empty() => rr.center().y - 7.0,
_ => rr.center().y,
};
let label_g = ui
.painter()
.layout_no_wrap(r.label.clone(), font.clone(), color);
ui.painter().galley(
egui::pos2(rr.left() + indent + 4.0, y - label_g.size().y / 2.0),
label_g,
// Labels stop short of the first amount column (notes,
// which have none, run the full width).
let label_room = if r.cells.is_empty() {
width - indent - 8.0
} else {
label_w - indent - 12.0
};
widgets::clipped_text(
ui.painter(),
egui::pos2(rr.left() + indent + 4.0, y),
&r.label,
font.clone(),
color,
label_room,
);
if !r.detail.is_empty() {
ui.painter().text(
widgets::clipped_text(
ui.painter(),
egui::pos2(rr.left() + indent + 4.0, rr.center().y + 9.0),
egui::Align2::LEFT_CENTER,
&r.detail,
theme::regular(11.5),
p.faint,
label_room,
);
}
for (i, c) in r.cells.iter().enumerate() {
+53 -52
View File
@@ -162,38 +162,10 @@ fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> eg
theme::semibold(16.0),
p.text,
);
// Description and accounts.
// 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_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 {
@@ -201,6 +173,35 @@ fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> eg
} 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,
@@ -234,7 +235,7 @@ fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> eg
};
if let Some((g, c)) = clip {
painter.text(
egui::pos2(rect.right() - amount_w - 24.0, rect.center().y),
egui::pos2(rect.right() - 10.0 - amount_w - 20.0, rect.center().y),
egui::Align2::CENTER_CENTER,
g,
egui::FontId::proportional(16.0),
@@ -244,27 +245,27 @@ fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> eg
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"),
],
);
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);