gui: every screen lays itself out to fit, down to a 360-point tiling slot

Narrow windows overlapped and clipped: the top bar's crumb, date, and
search ran into each other; big numbers wrapped mid-figure; tables
painted columns over each other; the entry and document panes squeezed
their lists to slivers; anything too wide widened the whole page.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-12 20:02:48 -06:00
1 parent 9eba3180b4
commit ea996f6e7e
16 files changed
+637 -196

No files matched your search

+241 -46
View File
@@ -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 search = egui::Button::new(
RichText::new(format!(
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(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,34 +641,52 @@ 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);
for (ok, text, tip) in checks(b) {
let (glyph, color) = if ok {
(icon::SEAL_CHECK, p.muted)
} else {
(icon::WARNING_CIRCLE, p.negative)
};
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()),
);
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 (ok, text, tip) = match &b.integrity.disk {
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()),
};
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(),
);
});
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 {
@@ -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;
+6 -1
View File
@@ -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,6 +285,10 @@ pub fn bar_chart(
color,
);
}
// 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,
@@ -292,6 +296,7 @@ pub fn bar_chart(
theme::regular(11.0),
p.faint,
);
}
if hovered {
let lines: Vec<String> = series
.iter()
+3 -1
View File
@@ -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(
+36 -10
View File
@@ -80,12 +80,27 @@ pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
theme::regular(13.0),
p.muted,
);
painter.text(
let bal_text = fmt::accounting(bal);
let bal_w = painter
.layout_no_wrap(bal_text.clone(), theme::semibold(13.5), p.text)
.size()
.x;
// Tags sit in their own column when there's room for
// it; narrower, they move to the row's tooltip.
let tags_x = 340.0;
let tags_room = r.width() - tags_x - bal_w - 24.0;
let name_room = if tags_room >= 120.0 {
tags_x - 64.0 - 12.0
} else {
r.width() - 64.0 - bal_w - 24.0
};
widgets::clipped_text(
&painter,
r.left_center() + vec2(64.0, 0.0),
egui::Align2::LEFT_CENTER,
&a.name,
theme::regular(13.5),
p.text,
name_room,
);
let mut tags = Vec::new();
if b.cash_accounts.contains(&a.id) {
@@ -103,20 +118,29 @@ pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
if a.placeholder {
tags.push("header".into());
}
painter.text(
r.left_center() + vec2(340.0, 0.0),
egui::Align2::LEFT_CENTER,
tags.join(" · "),
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,8 +230,9 @@ 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 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(
@@ -347,6 +372,7 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
true,
);
});
});
if let Some(entry) = go {
shell.go(Route::Entry(entry));
}
+2
View File
@@ -56,7 +56,9 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
// One tile per cash and card account.
let mut tiles: Vec<(u64, bool)> = b.cash_accounts.iter().map(|a| (*a, false)).collect();
tiles.extend(b.card_accounts.iter().map(|a| (*a, true)));
// Up to four across, fewer when the window is narrow.
let n = tiles.len().clamp(1, 4);
let n = widgets::fit_columns(ui.available_width(), 200.0, 16.0, n);
for row in tiles.chunks(n) {
columns(ui, 0.0, &vec![1.0; n], |i, ui| {
let Some((id, is_card)) = row.get(i) else {
+17 -9
View File
@@ -17,8 +17,16 @@ use rust_decimal::Decimal;
fn kv(ui: &mut Ui, k: &str, v: &str) {
let p = pal(ui.ctx());
// Narrow, the label goes above its value.
if ui.available_width() < 360.0 {
ui.label(RichText::new(k).font(theme::regular(12.0)).color(p.muted));
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(13.0))).wrap());
ui.add_space(4.0);
return;
}
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(180.0, 20.0), Sense::hover());
let kw = (ui.available_width() * 0.4).min(180.0);
let (r, _) = ui.allocate_exact_size(vec2(kw, 20.0), Sense::hover());
ui.painter().text(
r.left_center(),
egui::Align2::LEFT_CENTER,
@@ -79,7 +87,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
.all()
.filter(|a| a.account_type == ledger_core::AccountType::Equity)
{
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!("{} {}", a.code, a.name))
.font(theme::regular(13.0)),
@@ -101,7 +109,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
for m in &c.members {
let cap = b.ledger.chart().find_by_code(&m.capital_account);
let bal = cap.map(|a| b.balance_on(a.id, b.today)).unwrap_or_default();
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&m.name).font(theme::semibold(13.5)));
widgets::pill(
ui,
@@ -141,7 +149,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
ui.label(RichText::new("No periods yet. Closing a period locks it and moves its earnings into equity.").color(p.muted));
}
for per in periods {
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
let (g, color) = match per.status {
PeriodStatus::Closed => (icon::LOCK, p.muted),
PeriodStatus::Open => (icon::LOCK_OPEN, p.warning),
@@ -217,7 +225,7 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
} else {
(acc / dep).try_into().unwrap_or(0.0)
};
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
if widgets::link(
ui,
RichText::new(&a.description).font(theme::semibold(13.5)),
@@ -303,7 +311,7 @@ fn schedules(ui: &mut Ui, b: &Books) {
((total - remaining) / total).try_into().unwrap_or(0.0)
};
let due = s.due(b.today);
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&s.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!(
@@ -361,7 +369,7 @@ fn recurring(ui: &mut Ui, b: &Books) {
.filter(|d| r.end.is_none_or(|e| *d <= e))
})
.find(|d| !r.generated.contains(d));
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&r.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!(
@@ -424,7 +432,7 @@ fn parties(ui: &mut Ui, b: &Books) {
.find(|x| x.vendor_id == v.id)
.map(|x| x.paid)
.unwrap_or_default();
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&v.name).font(theme::semibold(13.0)));
if let Some(f) = v.form_1099 {
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
@@ -470,7 +478,7 @@ fn parties(ui: &mut Ui, b: &Books) {
.filter(|i| i.customer_id == c.id)
.map(|i| i.balance())
.sum();
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&c.name).font(theme::semibold(13.0)));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
+8 -6
View File
@@ -57,7 +57,7 @@ fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!("{} Search file names", icon::MAGNIFYING_GLASS))
.desired_width(260.0),
.desired_width(ui.available_width().min(260.0)),
);
if ui.selectable_label(st.category.is_none(), "All").clicked() {
st.category = None;
@@ -94,10 +94,10 @@ fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
widgets::card(ui, |ui| widgets::empty(ui, icon::FILES, "No documents", ""));
return;
}
let tile_w = 200.0;
let per_row = ((ui.available_width() + 16.0) / (tile_w + 16.0))
.floor()
.max(1.0) as usize;
// As many 200-point tiles as fit, stretched to fill the row.
let per_row = widgets::fit_columns(ui.available_width(), 200.0, 16.0, usize::MAX);
let tile_w =
((ui.available_width() - 16.0 * (per_row as f32 - 1.0)) / per_row as f32).min(280.0);
let mut go = None;
for row in docs.chunks(per_row) {
ui.horizontal_top(|ui| {
@@ -277,7 +277,9 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
format!("#{} {} ({})", x.id, x.description, fmt::dollars(x.amount))
})
.unwrap_or_else(|| format!("#{e}"));
if widgets::link(ui, RichText::new(label).color(p.accent)).clicked() {
if widgets::link_truncated(ui, RichText::new(label).color(p.accent))
.clicked()
{
shell.go(Route::Entry(e));
}
ui.end_row();
+4 -4
View File
@@ -110,14 +110,14 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
ui.end_row();
if let Some(c) = &created {
k(ui, "Recorded");
ui.label(c);
ui.add(egui::Label::new(c).wrap());
ui.end_row();
}
if e.status == EntryStatus::Posted
&& let Some(t) = &posted
{
k(ui, "Posted");
ui.label(t);
ui.add(egui::Label::new(t).wrap());
ui.end_row();
}
});
@@ -126,7 +126,7 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
// Lines.
section(ui, "Lines");
let total_w = ui.available_width();
let amt_w = 104.0;
let amt_w = ((total_w - 26.0) * 0.25).clamp(64.0, 104.0);
let class_names: std::collections::HashMap<u32, String> = b
.ledger
.list_classes()
@@ -159,7 +159,7 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
ui.allocate_ui(vec2(w, 22.0), |ui| {
ui.set_width(w);
ui.horizontal(|ui| {
let r = widgets::link(
let r = widgets::link_truncated(
ui,
RichText::new(b.account_label(line.account))
.font(theme::regular(13.5)),
+20 -10
View File
@@ -343,11 +343,11 @@ fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
),
);
let st = &mut shell.state.journal;
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!("{} Filter events", icon::MAGNIFYING_GLASS))
.desired_width(320.0),
.desired_width(ui.available_width().min(320.0)),
);
ui.checkbox(&mut st.hide_steps, "Hide entry-building steps");
});
@@ -401,20 +401,30 @@ fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
egui::FontId::proportional(15.0),
p.muted,
);
// The timestamp yields to the event when it's tight.
let ts = painter.layout_no_wrap(
fmt::timestamp(&line.ts),
theme::regular(12.0),
p.faint,
);
let ts_w = if rect.width() - 80.0 - ts.size().x - 24.0 >= 200.0 {
let w = ts.size().x;
painter.galley(
egui::pos2(rect.right() - 8.0 - w, y - ts.size().y / 2.0),
ts,
p.faint,
);
w + 24.0
} else {
8.0
};
widgets::clipped_text(
&painter,
egui::pos2(rect.left() + 80.0, y),
&ev.text,
theme::regular(13.0),
p.text,
rect.width() - 80.0 - 200.0,
);
painter.text(
egui::pos2(rect.right() - 8.0, y),
egui::Align2::RIGHT_CENTER,
fmt::timestamp(&line.ts),
theme::regular(12.0),
p.faint,
rect.width() - 80.0 - ts_w,
);
if resp.clicked() {
shell.go(Route::JournalEvent(line.seq));
+53 -5
View File
@@ -18,19 +18,25 @@ use egui::{Margin, Ui};
/// Standard page body: scrollable, padded.
pub fn page(ui: &mut Ui, id: &str, add: impl FnOnce(&mut Ui)) {
// Pinned to the window: something too wide is clipped (and found in
// review) rather than widening the page past its edge, which would
// carry over to the next frame and push everything else off screen.
let width = ui.available_width();
let side: i8 = if width < 560.0 { 16 } else { 32 };
egui::ScrollArea::vertical()
.id_salt(id)
.max_width(width)
.auto_shrink(false)
.show(ui, |ui| {
egui::Frame::new()
.inner_margin(Margin {
left: 32,
right: 32,
left: side,
right: side,
top: 24,
bottom: 40,
})
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_width((width - 2.0 * side as f32).max(0.0));
add(ui)
});
});
@@ -89,8 +95,37 @@ pub fn columns(ui: &mut Ui, min_width: f32, weights: &[f32], mut add: impl FnMut
ui.add_space(gap);
}
const SPLIT_MIN_LIST: f32 = 380.0;
const SPLIT_MIN_DETAIL: f32 = 360.0;
/// Tiles in rows: as many columns as fit at `min_width` each (up to
/// `max`), evened out across the rows, the tiles stretched to fill. `add(i, ui)` fills tile `i`.
pub fn grid(
ui: &mut Ui,
n: usize,
min_width: f32,
max: usize,
mut add: impl FnMut(usize, &mut Ui),
) {
let fit = crate::widgets::fit_columns(ui.available_width(), min_width, 16.0, max);
// Balanced rows: six tiles that fit five across go three and three,
// not five and one.
let rows = n.max(1).div_ceil(fit);
let per_row = n.max(1).div_ceil(rows);
let idx: Vec<usize> = (0..n).collect();
for row in idx.chunks(per_row) {
columns(ui, 0.0, &vec![1.0; per_row], |i, ui| {
if let Some(&t) = row.get(i) {
add(t, ui);
}
});
}
}
/// A list on the left and a detail pane on the right, split by a
/// draggable divider. The split is remembered per `id`.
/// draggable divider. The split is remembered per `id`. Too narrow for
/// both, the detail takes the whole width (its close button goes back to
/// the list).
pub fn split<T: ?Sized>(
ui: &mut Ui,
id: &str,
@@ -101,12 +136,25 @@ pub fn split<T: ?Sized>(
) {
let p = crate::theme::pal(ui.ctx());
let rect = ui.available_rect_before_wrap();
if rect.width() < SPLIT_MIN_LIST + SPLIT_MIN_DETAIL {
let inner = rect.shrink2(egui::vec2(16.0, 16.0));
ui.painter().rect_filled(rect, 0.0, p.surface);
let mut r = ui.new_child(
egui::UiBuilder::new()
.max_rect(inner)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
r.set_clip_rect(rect);
right(&mut r, state);
ui.allocate_rect(rect, egui::Sense::hover());
return;
}
let key = egui::Id::new(("split", id));
let mut detail_w: f32 = ui
.ctx()
.data_mut(|d| d.get_persisted(key))
.unwrap_or(default_detail);
detail_w = detail_w.clamp(360.0, (rect.width() - 380.0).max(360.0));
detail_w = detail_w.clamp(SPLIT_MIN_DETAIL, rect.width() - SPLIT_MIN_LIST);
let divider_x = rect.right() - detail_w;
let left_rect = egui::Rect::from_min_max(rect.min, egui::pos2(divider_x, rect.bottom()));
let right_rect = egui::Rect::from_min_max(egui::pos2(divider_x + 1.0, rect.top()), rect.max);
+2 -7
View File
@@ -223,11 +223,8 @@ 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) {
super::grid(ui, tiles.len(), 150.0, 6, |i, ui| {
let (tile, route) = &tiles[i];
let r = widgets::kpi(
ui,
Kpi {
@@ -241,9 +238,7 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
if r.interact(egui::Sense::click()).clicked() {
shell.go(route.clone());
}
}
});
}
}
/// `change` picks from the light palette; map to the current one.
+13 -7
View File
@@ -90,8 +90,9 @@ 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 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(
@@ -152,6 +153,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
}
}
});
},
);
ui.add_space(16.0);
@@ -210,8 +212,10 @@ 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 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()
@@ -237,12 +241,13 @@ fn spending_changes(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
}
ui.painter().text(
widgets::clipped_text(
ui.painter(),
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
format!("{code} {name}"),
&format!("{code} {name}"),
theme::regular(13.0),
p.text,
w - cw * 3.0 - 8.0,
);
// Flag a category already running well above its average.
let hot = *avg > Decimal::ZERO && *now > *avg * Decimal::new(125, 2);
@@ -270,6 +275,7 @@ fn spending_changes(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
go = b.ledger.chart().find_by_code(code).map(|a| a.id);
}
}
});
},
);
if let Some(id) = go {
+96 -35
View File
@@ -859,23 +859,8 @@ fn aging(rows: Vec<ledger_core::AgingRow>, as_of: NaiveDate) -> Table {
// ----------------------------------------------------------------------
// The page.
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();
let nav_w = 210.0;
let nav_rect = egui::Rect::from_min_size(rect.min, vec2(nav_w, rect.height()));
ui.painter().line_segment(
[nav_rect.right_top(), nav_rect.right_bottom()],
Stroke::new(1.0, p.border),
);
let mut nav = ui.new_child(egui::UiBuilder::new().max_rect(nav_rect.shrink2(vec2(12.0, 24.0))));
nav.label(
RichText::new("REPORTS")
.font(theme::semibold(11.5))
.color(p.faint),
);
nav.add_space(4.0);
let groups: [(&str, &[ReportKind]); 3] = [
/// The reports, grouped as the side list shows them.
const GROUPS: [(&str, &[ReportKind]); 3] = [
(
"Statements",
&[
@@ -898,8 +883,33 @@ pub fn show(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) {
ReportKind::ApAging,
],
),
];
for (g, kinds) in groups {
];
/// 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(
[nav_rect.right_top(), nav_rect.right_bottom()],
Stroke::new(1.0, p.border),
);
let mut nav = ui.new_child(egui::UiBuilder::new().max_rect(nav_rect.shrink2(vec2(12.0, 24.0))));
nav.label(
RichText::new("REPORTS")
.font(theme::semibold(11.5))
.color(p.faint),
);
nav.add_space(4.0);
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() {
+48 -47
View File
@@ -162,38 +162,10 @@ fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> eg
theme::semibold(16.0),
p.text,
);
// Description and accounts.
// Description and accounts, cut with an ellipsis before the receipt
// icon and the amount. Cash in reads green with a plus, cash out with
// a minus; entries that don't move cash show their size, muted.
let text_left = left + 44.0;
let amount_w = 120.0;
let max_w = (rect.right() - amount_w - 70.0 - text_left).max(60.0);
let desc = painter.layout(e.description.clone(), theme::semibold(13.5), p.text, max_w);
let desc = if desc.rows.len() > 1 {
painter.layout_no_wrap(
truncate(&e.description, (max_w / 7.5) as usize),
theme::semibold(13.5),
p.text,
)
} else {
desc
};
painter.galley(egui::pos2(text_left, rect.center().y - 17.0), desc, p.text);
let accounts: Vec<String> = e.accounts.iter().map(|a| b.account_name(*a)).collect();
let mut sub = format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → "));
if let Some(v) = &e.party {
sub.push_str(&format!(" · {v}"));
}
let sub = truncate(&sub, (max_w / 6.3) as usize);
painter.text(
egui::pos2(text_left, rect.center().y + 10.0),
egui::Align2::LEFT_CENTER,
sub,
theme::regular(12.0),
p.muted,
);
// Amount and badges.
let right = rect.right() - 10.0;
// Cash in reads green with a plus, cash out with a minus; entries that
// don't move cash show their size, muted.
let (amount, color) = if e.cash_effect > rust_decimal::Decimal::ZERO {
(format!("+{}", fmt::money(e.cash_effect)), p.positive)
} else if e.cash_effect < rust_decimal::Decimal::ZERO {
@@ -201,6 +173,35 @@ fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> eg
} else {
(fmt::money(e.amount), p.muted)
};
let amount_w = painter
.layout_no_wrap(amount.clone(), theme::semibold(14.0), color)
.size()
.x
.max(64.0);
let max_w = (rect.right() - 10.0 - amount_w - 44.0 - text_left).max(40.0);
widgets::clipped_text(
&painter,
egui::pos2(text_left, rect.center().y - 8.0),
&e.description,
theme::semibold(13.5),
p.text,
max_w,
);
let accounts: Vec<String> = e.accounts.iter().map(|a| b.account_name(*a)).collect();
let mut sub = format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → "));
if let Some(v) = &e.party {
sub.push_str(&format!(" · {v}"));
}
widgets::clipped_text(
&painter,
egui::pos2(text_left, rect.center().y + 10.0),
&sub,
theme::regular(12.0),
p.muted,
max_w,
);
// Amount and badges.
let right = rect.right() - 10.0;
painter.text(
egui::pos2(right, rect.center().y - 6.0),
egui::Align2::RIGHT_CENTER,
@@ -234,7 +235,7 @@ fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> eg
};
if let Some((g, c)) = clip {
painter.text(
egui::pos2(rect.right() - amount_w - 24.0, rect.center().y),
egui::pos2(rect.right() - 10.0 - amount_w - 20.0, rect.center().y),
egui::Align2::CENTER_CENTER,
g,
egui::FontId::proportional(16.0),
@@ -244,27 +245,27 @@ fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> eg
resp
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
s.to_string()
} else {
let t: String = s.chars().take(n.saturating_sub(1)).collect();
format!("{t}…")
}
}
fn filters(ui: &mut Ui, b: &Books, st: &mut State) {
widgets::segmented(
ui,
&mut st.filter,
&[
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);
+73 -2
View File
@@ -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<R>(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))
}
+15 -6
View File
@@ -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),