From ea996f6e7e4763168595b3a6ef6702ce83b47493 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 12 Oct 2026 20:02:48 -0600 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-gui/src/app.rs | 307 ++++++++++++++++---- crates/ledger-gui/src/charts.rs | 21 +- crates/ledger-gui/src/main.rs | 4 +- crates/ledger-gui/src/pages/accounts.rs | 306 ++++++++++--------- crates/ledger-gui/src/pages/cash.rs | 2 + crates/ledger-gui/src/pages/company.rs | 26 +- crates/ledger-gui/src/pages/documents.rs | 14 +- crates/ledger-gui/src/pages/entry.rs | 8 +- crates/ledger-gui/src/pages/journal.rs | 30 +- crates/ledger-gui/src/pages/mod.rs | 58 +++- crates/ledger-gui/src/pages/overview.rs | 37 +-- crates/ledger-gui/src/pages/profit.rs | 210 ++++++------- crates/ledger-gui/src/pages/reports.rs | 143 ++++++--- crates/ledger-gui/src/pages/transactions.rs | 105 +++---- crates/ledger-gui/src/widgets.rs | 75 ++++- crates/ledger-gui/tests/screens.rs | 21 +- 16 files changed, 904 insertions(+), 463 deletions(-) diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 1767b18..779325e 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -12,6 +12,9 @@ use egui::{ use egui_phosphor::regular as icon; use std::sync::Arc; +/// Window widths below this get the icon rail instead of the sidebar. +pub const COMPACT_BELOW: f32 = 1000.0; + /// Per-page state that survives navigation. #[derive(Default)] pub struct PageState { @@ -227,7 +230,12 @@ impl App { } } - fn sidebar(&mut self, ui: &mut Ui) { + /// The sidebar: full below [`COMPACT_BELOW`] window widths, an icon + /// rail (names in tooltips) when the window is narrower. + fn sidebar(&mut self, ui: &mut Ui, compact: bool) { + if compact { + return self.rail(ui); + } let p = pal(ui.ctx()); let books = self.books.clone(); egui::Panel::left("sidebar") @@ -361,6 +369,139 @@ impl App { }); } + /// The sidebar as a narrow rail of icons, for narrow windows. + fn rail(&mut self, ui: &mut Ui) { + let p = pal(ui.ctx()); + let books = self.books.clone(); + egui::Panel::left("rail") + .exact_size(RAIL_W) + .resizable(false) + .frame( + Frame::new() + .fill(p.sidebar) + .inner_margin(Margin::symmetric(10, 16)), + ) + .show(ui, |ui| { + let name = books + .as_ref() + .map(|b| b.company_name().to_string()) + .unwrap_or_else(|| "wanderbooks".into()); + let (r, _) = + ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); + let logo = egui::Rect::from_center_size(r.center(), vec2(28.0, 28.0)); + ui.painter() + .rect_filled(logo, CornerRadius::same(8), p.accent); + ui.painter().text( + logo.center(), + egui::Align2::CENTER_CENTER, + name.chars().next().unwrap_or('W'), + theme::bold(15.0), + Color32::WHITE, + ); + ui.interact(r, ui.id().with("logo"), Sense::hover()) + .on_hover_text(format!("{name} · Books")); + ui.add_space(18.0); + let current = self.nav.current.section(); + let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0); + for s in Section::ALL { + let on = s == current; + let (rect, resp) = + ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); + if on { + ui.painter() + .rect_filled(rect, CornerRadius::same(8), p.surface); + ui.painter().rect_stroke( + rect, + CornerRadius::same(8), + Stroke::new(1.0, p.border), + egui::StrokeKind::Inside, + ); + } else if resp.hovered() { + ui.painter() + .rect_filled(rect, CornerRadius::same(8), p.hover); + } + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + s.icon(), + egui::FontId::proportional(18.0), + if on { p.accent } else { p.muted }, + ); + let tip = if s == Section::Overview && attention > 0 { + ui.painter() + .circle_filled(rect.center() + vec2(9.0, -8.0), 4.0, p.warning); + format!("{} · {attention} need attention", s.title()) + } else { + s.title().to_string() + }; + if resp.on_hover_text(tip).clicked() { + self.nav.go(s.home()); + } + } + ui.with_layout(Layout::bottom_up(Align::Center), |ui| { + let (glyph, tip) = if self.dark { + (icon::SUN, "Light mode") + } else { + (icon::MOON, "Dark mode") + }; + if ui + .add( + egui::Button::new(RichText::new(glyph).size(16.0).color(p.muted)) + .frame(false) + .min_size(vec2(32.0, 32.0)), + ) + .on_hover_text(tip) + .clicked() + { + self.dark = !self.dark; + theme::apply(ui.ctx(), self.dark); + } + ui.add_space(6.0); + if let Some(b) = &books { + let live = self.loader.is_some() || self.live_label; + let checks = checks(b); + let ok = checks.iter().all(|c| c.0); + let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::hover()); + if ok { + let pulse = (ui.input(|i| i.time) - self.updated_at < 2.0) as u8 as f32; + ui.painter().circle_filled( + r.center(), + 4.0 + pulse, + if live { p.positive } else { p.faint }, + ); + } else { + ui.painter().text( + r.center(), + egui::Align2::CENTER_CENTER, + icon::WARNING_CIRCLE, + egui::FontId::proportional(18.0), + p.negative, + ); + } + let mut tip = format!( + "{} · event {}", + if live { "Live" } else { "Snapshot" }, + b.seq() + ); + for (ok, text, _) in &checks { + tip.push_str(&format!( + "\n{} {text}", + if *ok { + icon::SEAL_CHECK + } else { + icon::WARNING_CIRCLE + } + )); + } + resp.on_hover_text(tip); + } + }); + }); + } + + /// Back, forward, where you are, today, and search. Squeezed, the + /// search shrinks to its icon, the date goes, and the crumb truncates: + /// nothing ever overlaps. fn topbar(&mut self, ui: &mut Ui) { let p = pal(ui.ctx()); egui::Panel::top("topbar") @@ -368,10 +509,11 @@ impl App { .frame( Frame::new() .fill(p.bg) - .inner_margin(Margin::symmetric(20, 10)) + .inner_margin(Margin::symmetric(16, 10)) .stroke(Stroke::new(1.0, p.border)), ) .show(ui, |ui| { + let width = ui.available_width(); ui.horizontal_centered(|ui| { let nav_btn = |ui: &mut Ui, glyph: &str, enabled: bool, tip: &str| { ui.add_enabled( @@ -395,52 +537,86 @@ impl App { self.nav.forward(); } ui.add_space(6.0); - let crumb = self - .books - .as_ref() - .map(|b| crate::palette::describe(b, &self.nav.current)) - .unwrap_or_default(); - ui.label( - RichText::new(self.nav.current.section().title()) - .font(theme::regular(13.5)) - .color(p.muted), - ); - if !crumb.is_empty() { - ui.label(RichText::new(icon::CARET_RIGHT).color(p.faint)); - ui.add( - egui::Label::new(RichText::new(crumb).font(theme::semibold(13.5))) - .truncate(), - ); - } + // Right side first, so the crumb gets only what's left. ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let (text, min_w) = if width >= 640.0 { + ( + format!( + "{} Search everything… Ctrl K", + icon::MAGNIFYING_GLASS + ), + 260.0, + ) + } else if width >= 420.0 { + (format!("{} Search", icon::MAGNIFYING_GLASS), 0.0) + } else { + (icon::MAGNIFYING_GLASS.to_string(), 30.0) + }; let search = egui::Button::new( - RichText::new(format!( - "{} Search everything… Ctrl K", - icon::MAGNIFYING_GLASS - )) - .font(theme::regular(13.0)) - .color(p.faint), + RichText::new(text) + .font(theme::regular(13.0)) + .color(p.faint), ) .fill(p.surface) .stroke(Stroke::new(1.0, p.border)) .corner_radius(CornerRadius::same(8)) - .min_size(vec2(260.0, 30.0)); - if ui.add(search).clicked() { + .min_size(vec2(min_w, 30.0)); + if ui + .add(search) + .on_hover_text("Search everything (Ctrl K)") + .clicked() + { self.palette.open(); } - if let Some(b) = &self.books { + if let Some(b) = &self.books + && width >= 860.0 + { ui.label( RichText::new(format!("Today {}", crate::fmt::date(b.today))) .font(theme::regular(12.5)) .color(p.muted), ); } + ui.add_space(8.0); + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + let crumb = self + .books + .as_ref() + .map(|b| crate::palette::describe(b, &self.nav.current)) + .unwrap_or_default(); + let section = self.nav.current.section().title(); + // Narrow, the crumb alone says where you are. + if crumb.is_empty() || ui.available_width() >= 260.0 { + ui.add( + egui::Label::new( + RichText::new(section) + .font(theme::regular(13.5)) + .color(p.muted), + ) + .truncate(), + ); + } + if !crumb.is_empty() { + if ui.available_width() >= 260.0 - 80.0 { + ui.label(RichText::new(icon::CARET_RIGHT).color(p.faint)); + } + ui.add( + egui::Label::new( + RichText::new(crumb).font(theme::semibold(13.5)), + ) + .truncate(), + ); + } + }); }); }); }); } } +/// Width of the icon rail. +const RAIL_W: f32 = 60.0; + fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) { let p = pal(ui.ctx()); Frame::new() @@ -465,36 +641,54 @@ fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) { .color(p.muted), ); }); - let check = |ui: &mut Ui, ok: bool, text: String, tip: String| { - let (glyph, color) = if ok { (icon::SEAL_CHECK, p.muted) } else { (icon::WARNING_CIRCLE, p.negative) }; - ui.label(RichText::new(format!("{glyph} {text}")).font(theme::regular(11.5)).color(color)) - .on_hover_text(tip); - }; - check( - ui, - true, - "Journal chain verified".into(), - format!("Every journal event's hash chains to the one before it.\nHead: {}\nData: {}", b.head, b.data_dir.display()), - ); - use ledger_store::DiskCheck; - let (ok, text, tip) = match &b.integrity.disk { - DiskCheck::Matches => (true, "Database matches journal".to_string(), "The server's SQLite database matches a fresh projection of the journal, every table, row for row.".to_string()), - DiskCheck::Behind { applied, journal } => (true, "Database catching up".to_string(), format!("The database has applied {applied} of {journal} journal events; the server applies the rest right after writing them.")), - DiskCheck::Missing => (true, "Read-only copy".to_string(), "No server database here: a copy of the books (a CPA's, say), or the server hasn't run yet. The journal and every file are verified either way.".to_string()), - DiskCheck::Differs(why) => (false, "Database differs".to_string(), format!("{why}. The server rebuilds it from the journal (books.verify, or on its next start).")), - DiskCheck::Unreadable(why) => (false, "Database unreadable".to_string(), why.clone()), - }; - check(ui, ok, text, tip); - let bad = b.integrity.file_failures.len(); - check( - ui, - bad == 0, - if bad == 0 { format!("{} files verified", b.integrity.files_ok) } else { format!("{bad} files damaged") }, - "Every stored receipt and document re-hashed against its recorded SHA-256.".into(), - ); + for (ok, text, tip) in checks(b) { + let (glyph, color) = if ok { + (icon::SEAL_CHECK, p.muted) + } else { + (icon::WARNING_CIRCLE, p.negative) + }; + ui.label( + RichText::new(format!("{glyph} {text}")) + .font(theme::regular(11.5)) + .color(color), + ) + .on_hover_text(tip); + } }); } +/// The integrity checks the sidebar shows: (ok, label, explanation). +fn checks(b: &Books) -> Vec<(bool, String, String)> { + use ledger_store::DiskCheck; + let mut out = vec![( + true, + "Journal chain verified".to_string(), + format!( + "Every journal event's hash chains to the one before it.\nHead: {}\nData: {}", + b.head, + b.data_dir.display() + ), + )]; + out.push(match &b.integrity.disk { + DiskCheck::Matches => (true, "Database matches journal".to_string(), "The server's SQLite database matches a fresh projection of the journal, every table, row for row.".to_string()), + DiskCheck::Behind { applied, journal } => (true, "Database catching up".to_string(), format!("The database has applied {applied} of {journal} journal events; the server applies the rest right after writing them.")), + DiskCheck::Missing => (true, "Read-only copy".to_string(), "No server database here: a copy of the books (a CPA's, say), or the server hasn't run yet. The journal and every file are verified either way.".to_string()), + DiskCheck::Differs(why) => (false, "Database differs".to_string(), format!("{why}. The server rebuilds it from the journal (books.verify, or on its next start).")), + DiskCheck::Unreadable(why) => (false, "Database unreadable".to_string(), why.clone()), + }); + let bad = b.integrity.file_failures.len(); + out.push(( + bad == 0, + if bad == 0 { + format!("{} files verified", b.integrity.files_ok) + } else { + format!("{bad} files damaged") + }, + "Every stored receipt and document re-hashed against its recorded SHA-256.".into(), + )); + out +} + impl eframe::App for App { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { let updates = self.loader.as_ref().map(|l| l.poll()).unwrap_or_default(); @@ -513,7 +707,8 @@ impl eframe::App for App { } self.receipts.pump(ui.ctx()); self.handle_keys(ui); - self.sidebar(ui); + let compact = ui.available_width() < COMPACT_BELOW; + self.sidebar(ui, compact); self.topbar(ui); let p = pal(ui.ctx()); let since_update = ui.input(|i| i.time) - self.updated_at; diff --git a/crates/ledger-gui/src/charts.rs b/crates/ledger-gui/src/charts.rs index a81c92a..2b8e31c 100644 --- a/crates/ledger-gui/src/charts.rs +++ b/crates/ledger-gui/src/charts.rs @@ -83,7 +83,7 @@ pub fn balance_line(ui: &mut Ui, points: &[(NaiveDate, Decimal)], until: NaiveDa ); } // X labels: first, last, and a few between. - let n_labels = 4; + let n_labels = ((plot.width() / 90.0) as usize).clamp(1, 4); for i in 0..=n_labels { let d = first + chrono::Days::new(((days * i as f64) / n_labels as f64).round() as u64); let align = match i { @@ -285,13 +285,18 @@ pub fn bar_chart( color, ); } - painter.text( - pos2(gx + gw / 2.0, plot.bottom() + 6.0), - Align2::CENTER_TOP, - &g.label, - theme::regular(11.0), - p.faint, - ); + // Crowded, label every other group (or fewer), counting back from + // the latest so it's always labeled. + let every = (34.0 / gw).ceil().max(1.0) as usize; + if (groups.len() - 1 - gi).is_multiple_of(every) { + painter.text( + pos2(gx + gw / 2.0, plot.bottom() + 6.0), + Align2::CENTER_TOP, + &g.label, + theme::regular(11.0), + p.faint, + ); + } if hovered { let lines: Vec = series .iter() diff --git a/crates/ledger-gui/src/main.rs b/crates/ledger-gui/src/main.rs index 58d750a..f78f207 100644 --- a/crates/ledger-gui/src/main.rs +++ b/crates/ledger-gui/src/main.rs @@ -61,7 +61,9 @@ fn main() -> eframe::Result<()> { .with_title("wanderbooks") .with_app_id("wanderbooks") .with_inner_size([1440.0, 920.0]) - .with_min_inner_size([900.0, 600.0]), + // Small on purpose: tiling window managers hand out small slots + // regardless, and every screen lays itself out to fit. + .with_min_inner_size([360.0, 400.0]), ..Default::default() }; eframe::run_native( diff --git a/crates/ledger-gui/src/pages/accounts.rs b/crates/ledger-gui/src/pages/accounts.rs index d4c2bf8..92ef8bf 100644 --- a/crates/ledger-gui/src/pages/accounts.rs +++ b/crates/ledger-gui/src/pages/accounts.rs @@ -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)); diff --git a/crates/ledger-gui/src/pages/cash.rs b/crates/ledger-gui/src/pages/cash.rs index db7a19a..d2a17fc 100644 --- a/crates/ledger-gui/src/pages/cash.rs +++ b/crates/ledger-gui/src/pages/cash.rs @@ -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 { diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index 06571a8..f80e7d5 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -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), diff --git a/crates/ledger-gui/src/pages/documents.rs b/crates/ledger-gui/src/pages/documents.rs index e837877..bf22c97 100644 --- a/crates/ledger-gui/src/pages/documents.rs +++ b/crates/ledger-gui/src/pages/documents.rs @@ -57,7 +57,7 @@ fn grid(ui: &mut Ui, b: &Books, selected: Option, 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, 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(); diff --git a/crates/ledger-gui/src/pages/entry.rs b/crates/ledger-gui/src/pages/entry.rs index b70784c..f74f8e0 100644 --- a/crates/ledger-gui/src/pages/entry.rs +++ b/crates/ledger-gui/src/pages/entry.rs @@ -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 = 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)), diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index fbabf27..d1e1340 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -343,11 +343,11 @@ fn list(ui: &mut Ui, b: &Books, selected: Option, 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, 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)); diff --git a/crates/ledger-gui/src/pages/mod.rs b/crates/ledger-gui/src/pages/mod.rs index 828c6be..090a144 100644 --- a/crates/ledger-gui/src/pages/mod.rs +++ b/crates/ledger-gui/src/pages/mod.rs @@ -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 = (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( ui: &mut Ui, id: &str, @@ -101,12 +136,25 @@ pub fn split( ) { 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); diff --git a/crates/ledger-gui/src/pages/overview.rs b/crates/ledger-gui/src/pages/overview.rs index 45346b5..24dcf89 100644 --- a/crates/ledger-gui/src/pages/overview.rs +++ b/crates/ledger-gui/src/pages/overview.rs @@ -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. diff --git a/crates/ledger-gui/src/pages/profit.rs b/crates/ledger-gui/src/pages/profit.rs index 6e5b8c5..7fd103a 100644 --- a/crates/ledger-gui/src/pages/profit.rs +++ b/crates/ledger-gui/src/pages/profit.rs @@ -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 { diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index 6e88512..a32184d 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -859,9 +859,43 @@ fn aging(rows: Vec, 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() { diff --git a/crates/ledger-gui/src/pages/transactions.rs b/crates/ledger-gui/src/pages/transactions.rs index eaa595b..b6d34de 100644 --- a/crates/ledger-gui/src/pages/transactions.rs +++ b/crates/ledger-gui/src/pages/transactions.rs @@ -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 = 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 = 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); diff --git a/crates/ledger-gui/src/widgets.rs b/crates/ledger-gui/src/widgets.rs index ebafbf1..e3d211e 100644 --- a/crates/ledger-gui/src/widgets.rs +++ b/crates/ledger-gui/src/widgets.rs @@ -75,8 +75,17 @@ pub fn pill(ui: &mut Ui, text: &str, fg: Color32, bg: Color32) -> Response { /// Text that looks like plain text until you hover it, then like a link. pub fn link(ui: &mut Ui, text: RichText) -> Response { + link_label(ui, egui::Label::new(text)) +} + +/// [`link`] on one line, cut with an ellipsis where the space ends. +pub fn link_truncated(ui: &mut Ui, text: RichText) -> Response { + link_label(ui, egui::Label::new(text).truncate()) +} + +fn link_label(ui: &mut Ui, label: egui::Label) -> Response { let p = pal(ui.ctx()); - let r = ui.add(egui::Label::new(text.clone()).sense(Sense::click())); + let r = ui.add(label.sense(Sense::click())); if r.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); let rect = r.rect; @@ -163,7 +172,15 @@ pub fn kpi(ui: &mut Ui, k: Kpi<'_>) -> Response { .color(p.muted), ); ui.add_space(4.0); - ui.label(RichText::new(&k.value).font(theme::semibold(24.0))); + let font = fit_font( + ui, + &k.value, + theme::semibold, + 24.0, + 15.0, + ui.available_width(), + ); + ui.add(egui::Label::new(RichText::new(&k.value).font(font)).truncate()); if let Some((text, color)) = &k.sub { ui.add_space(2.0); ui.label(RichText::new(text).font(theme::regular(12.0)).color(*color)); @@ -313,3 +330,57 @@ pub fn clipped_text( let y = pos.y - galley.size().y / 2.0; painter.galley(egui::pos2(pos.x, y), galley, color); } + +/// How many columns at least `min_width` wide (with `gap` between) fit +/// in `avail`, never fewer than one nor more than `max`. +pub fn fit_columns(avail: f32, min_width: f32, gap: f32, max: usize) -> usize { + (((avail + gap) / (min_width + gap)).floor() as usize).clamp(1, max.max(1)) +} + +/// The width of each of `n` right-aligned amount columns in a painted +/// table `width` wide, leaving at least `label_min` for the label on the +/// left: `max` when there's room, shrinking to a floor that still fits an +/// amount. Pair with [`min_width`] so a table that can't fit scrolls. +pub fn amount_col_w(width: f32, n: usize, label_min: f32, max: f32) -> f32 { + ((width - label_min) / n.max(1) as f32).clamp(AMOUNT_MIN, max) +} + +/// The narrowest an amount column gets ("(1,234,567.89)" at 13.5px). +pub const AMOUNT_MIN: f32 = 92.0; + +/// Lay out `add` at least `width` wide: as is when there's room, in a +/// sideways scroll when the window is narrower, so a table never paints +/// over itself or past the edge. +pub fn min_width(ui: &mut Ui, id: &str, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { + if ui.available_width() >= width { + return add(ui); + } + egui::ScrollArea::horizontal() + .id_salt(id) + .show(ui, |ui| { + ui.set_width(width); + add(ui) + }) + .inner +} + +/// The largest size up to `size` at which `text` fits on one line in +/// `width` (down to `floor`): big numbers shrink rather than wrap. +pub fn fit_font( + ui: &Ui, + text: &str, + font: impl Fn(f32) -> egui::FontId, + size: f32, + floor: f32, + width: f32, +) -> egui::FontId { + let measured = ui + .painter() + .layout_no_wrap(text.to_string(), font(size), Color32::WHITE) + .size() + .x; + if measured <= width { + return font(size); + } + font((size * width / measured).floor().max(floor)) +} diff --git a/crates/ledger-gui/tests/screens.rs b/crates/ledger-gui/tests/screens.rs index eb9fd14..d7bcd50 100644 --- a/crates/ledger-gui/tests/screens.rs +++ b/crates/ledger-gui/tests/screens.rs @@ -40,12 +40,12 @@ fn screenshots() { }; let only = std::env::var("SHOT_ONLY").ok(); let dark = std::env::var("SHOT_DARK").is_ok(); - let first_entry = data::load_once(&source) - .unwrap() - .entries - .last() - .map(|e| e.id) - .unwrap_or(1); + let loaded = data::load_once(&source).unwrap(); + let first_entry = loaded.entries.last().map(|e| e.id).unwrap_or(1); + let first_cash = loaded.cash_accounts.first().copied().unwrap_or(1); + let last_doc = loaded.attachments.last().map(|a| a.id).unwrap_or(1); + let last_event = loaded.seq(); + drop(loaded); let routes: Vec<(&str, Route)> = vec![ ("overview", Route::Overview), ("transactions", Route::Transactions), @@ -62,7 +62,16 @@ fn screenshots() { ("profit", Route::Profit), ("pnl", Route::Reports(ReportKind::ProfitAndLoss)), ("balance", Route::Reports(ReportKind::BalanceSheet)), + ("cashflow", Route::Reports(ReportKind::CashFlow)), + ("trial", Route::Reports(ReportKind::TrialBalance)), + ("gl", Route::Reports(ReportKind::GeneralLedger)), + ("tax", Route::Reports(ReportKind::TaxSummary)), + ("v1099", Route::Reports(ReportKind::Vendor1099)), + ("ar", Route::Reports(ReportKind::ArAging)), ("accounts", Route::Accounts), + ("account", Route::Account(first_cash)), + ("document", Route::Document(last_doc)), + ("event", Route::JournalEvent(last_event)), ("documents", Route::Documents), ("company", Route::Company), ("journal", Route::Journal),