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:
1 parent
3b274ed853
commit
6fb594ed28
16 files changed
+904
-463
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user