gui: every screen lays itself out to fit, down to a 360-point tiling slot
Narrow windows overlapped and clipped: the top bar's crumb, date, and search ran into each other; big numbers wrapped mid-figure; tables painted columns over each other; the entry and document panes squeezed their lists to slivers; anything too wide widened the whole page. - Below 1000 points the sidebar becomes an icon rail (names, attention, and integrity checks in tooltips). - The top bar lays out right to left: search shrinks to its icon, the date drops, the crumb truncates. - Pages are pinned to the window width with narrower margins when tight, so one wide thing can't push everything off screen. - Tiles (KPIs, cash accounts, documents) fit as many columns as the width allows, balanced across rows; KPI figures shrink rather than wrap. - Painted tables shrink their amount columns, clip labels with an ellipsis, then scroll sideways rather than overlap. - List/detail splits show only the detail when both won't fit. - Reports: the side list becomes a picker; controls wrap by known width. - Smaller fixes: chart labels thin out, company facts stack, journal timestamps yield, transaction filters become a dropdown. - Min window size drops to 360x400; the screenshot test covers every report, a register, a document, and a journal event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
9eba3180b4
commit
ea996f6e7e
16 files changed
+637
-196
No files matched your search
+241
-46
@@ -12,6 +12,9 @@ use egui::{
|
|||||||
use egui_phosphor::regular as icon;
|
use egui_phosphor::regular as icon;
|
||||||
use std::sync::Arc;
|
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.
|
/// Per-page state that survives navigation.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct PageState {
|
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 p = pal(ui.ctx());
|
||||||
let books = self.books.clone();
|
let books = self.books.clone();
|
||||||
egui::Panel::left("sidebar")
|
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) {
|
fn topbar(&mut self, ui: &mut Ui) {
|
||||||
let p = pal(ui.ctx());
|
let p = pal(ui.ctx());
|
||||||
egui::Panel::top("topbar")
|
egui::Panel::top("topbar")
|
||||||
@@ -368,10 +509,11 @@ impl App {
|
|||||||
.frame(
|
.frame(
|
||||||
Frame::new()
|
Frame::new()
|
||||||
.fill(p.bg)
|
.fill(p.bg)
|
||||||
.inner_margin(Margin::symmetric(20, 10))
|
.inner_margin(Margin::symmetric(16, 10))
|
||||||
.stroke(Stroke::new(1.0, p.border)),
|
.stroke(Stroke::new(1.0, p.border)),
|
||||||
)
|
)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
|
let width = ui.available_width();
|
||||||
ui.horizontal_centered(|ui| {
|
ui.horizontal_centered(|ui| {
|
||||||
let nav_btn = |ui: &mut Ui, glyph: &str, enabled: bool, tip: &str| {
|
let nav_btn = |ui: &mut Ui, glyph: &str, enabled: bool, tip: &str| {
|
||||||
ui.add_enabled(
|
ui.add_enabled(
|
||||||
@@ -395,52 +537,86 @@ impl App {
|
|||||||
self.nav.forward();
|
self.nav.forward();
|
||||||
}
|
}
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
let crumb = self
|
// Right side first, so the crumb gets only what's left.
|
||||||
.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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||||
let search = egui::Button::new(
|
let (text, min_w) = if width >= 640.0 {
|
||||||
RichText::new(format!(
|
(
|
||||||
|
format!(
|
||||||
"{} Search everything… Ctrl K",
|
"{} Search everything… Ctrl K",
|
||||||
icon::MAGNIFYING_GLASS
|
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))
|
.font(theme::regular(13.0))
|
||||||
.color(p.faint),
|
.color(p.faint),
|
||||||
)
|
)
|
||||||
.fill(p.surface)
|
.fill(p.surface)
|
||||||
.stroke(Stroke::new(1.0, p.border))
|
.stroke(Stroke::new(1.0, p.border))
|
||||||
.corner_radius(CornerRadius::same(8))
|
.corner_radius(CornerRadius::same(8))
|
||||||
.min_size(vec2(260.0, 30.0));
|
.min_size(vec2(min_w, 30.0));
|
||||||
if ui.add(search).clicked() {
|
if ui
|
||||||
|
.add(search)
|
||||||
|
.on_hover_text("Search everything (Ctrl K)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
self.palette.open();
|
self.palette.open();
|
||||||
}
|
}
|
||||||
if let Some(b) = &self.books {
|
if let Some(b) = &self.books
|
||||||
|
&& width >= 860.0
|
||||||
|
{
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(format!("Today {}", crate::fmt::date(b.today)))
|
RichText::new(format!("Today {}", crate::fmt::date(b.today)))
|
||||||
.font(theme::regular(12.5))
|
.font(theme::regular(12.5))
|
||||||
.color(p.muted),
|
.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) {
|
fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
|
||||||
let p = pal(ui.ctx());
|
let p = pal(ui.ctx());
|
||||||
Frame::new()
|
Frame::new()
|
||||||
@@ -465,34 +641,52 @@ fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
|
|||||||
.color(p.muted),
|
.color(p.muted),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
let check = |ui: &mut Ui, ok: bool, text: String, tip: String| {
|
for (ok, text, tip) in checks(b) {
|
||||||
let (glyph, color) = if ok { (icon::SEAL_CHECK, p.muted) } else { (icon::WARNING_CIRCLE, p.negative) };
|
let (glyph, color) = if ok {
|
||||||
ui.label(RichText::new(format!("{glyph} {text}")).font(theme::regular(11.5)).color(color))
|
(icon::SEAL_CHECK, p.muted)
|
||||||
.on_hover_text(tip);
|
} else {
|
||||||
|
(icon::WARNING_CIRCLE, p.negative)
|
||||||
};
|
};
|
||||||
check(
|
ui.label(
|
||||||
ui,
|
RichText::new(format!("{glyph} {text}"))
|
||||||
true,
|
.font(theme::regular(11.5))
|
||||||
"Journal chain verified".into(),
|
.color(color),
|
||||||
format!("Every journal event's hash chains to the one before it.\nHead: {}\nData: {}", b.head, b.data_dir.display()),
|
)
|
||||||
);
|
.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;
|
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::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::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::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::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()),
|
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 {
|
impl eframe::App for App {
|
||||||
@@ -513,7 +707,8 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
self.receipts.pump(ui.ctx());
|
self.receipts.pump(ui.ctx());
|
||||||
self.handle_keys(ui);
|
self.handle_keys(ui);
|
||||||
self.sidebar(ui);
|
let compact = ui.available_width() < COMPACT_BELOW;
|
||||||
|
self.sidebar(ui, compact);
|
||||||
self.topbar(ui);
|
self.topbar(ui);
|
||||||
let p = pal(ui.ctx());
|
let p = pal(ui.ctx());
|
||||||
let since_update = ui.input(|i| i.time) - self.updated_at;
|
let since_update = ui.input(|i| i.time) - self.updated_at;
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ pub fn balance_line(ui: &mut Ui, points: &[(NaiveDate, Decimal)], until: NaiveDa
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// X labels: first, last, and a few between.
|
// 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 {
|
for i in 0..=n_labels {
|
||||||
let d = first + chrono::Days::new(((days * i as f64) / n_labels as f64).round() as u64);
|
let d = first + chrono::Days::new(((days * i as f64) / n_labels as f64).round() as u64);
|
||||||
let align = match i {
|
let align = match i {
|
||||||
@@ -285,6 +285,10 @@ pub fn bar_chart(
|
|||||||
color,
|
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(
|
painter.text(
|
||||||
pos2(gx + gw / 2.0, plot.bottom() + 6.0),
|
pos2(gx + gw / 2.0, plot.bottom() + 6.0),
|
||||||
Align2::CENTER_TOP,
|
Align2::CENTER_TOP,
|
||||||
@@ -292,6 +296,7 @@ pub fn bar_chart(
|
|||||||
theme::regular(11.0),
|
theme::regular(11.0),
|
||||||
p.faint,
|
p.faint,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
if hovered {
|
if hovered {
|
||||||
let lines: Vec<String> = series
|
let lines: Vec<String> = series
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ fn main() -> eframe::Result<()> {
|
|||||||
.with_title("wanderbooks")
|
.with_title("wanderbooks")
|
||||||
.with_app_id("wanderbooks")
|
.with_app_id("wanderbooks")
|
||||||
.with_inner_size([1440.0, 920.0])
|
.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()
|
..Default::default()
|
||||||
};
|
};
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
|
|||||||
@@ -80,12 +80,27 @@ pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
theme::regular(13.0),
|
theme::regular(13.0),
|
||||||
p.muted,
|
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),
|
r.left_center() + vec2(64.0, 0.0),
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
&a.name,
|
&a.name,
|
||||||
theme::regular(13.5),
|
theme::regular(13.5),
|
||||||
p.text,
|
p.text,
|
||||||
|
name_room,
|
||||||
);
|
);
|
||||||
let mut tags = Vec::new();
|
let mut tags = Vec::new();
|
||||||
if b.cash_accounts.contains(&a.id) {
|
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 {
|
if a.placeholder {
|
||||||
tags.push("header".into());
|
tags.push("header".into());
|
||||||
}
|
}
|
||||||
painter.text(
|
let tags = tags.join(" · ");
|
||||||
r.left_center() + vec2(340.0, 0.0),
|
if tags_room >= 120.0 {
|
||||||
egui::Align2::LEFT_CENTER,
|
widgets::clipped_text(
|
||||||
tags.join(" · "),
|
&painter,
|
||||||
|
r.left_center() + vec2(tags_x, 0.0),
|
||||||
|
&tags,
|
||||||
theme::regular(12.0),
|
theme::regular(12.0),
|
||||||
p.faint,
|
p.faint,
|
||||||
|
tags_room,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
painter.text(
|
painter.text(
|
||||||
r.right_center() - vec2(8.0, 0.0),
|
r.right_center() - vec2(8.0, 0.0),
|
||||||
egui::Align2::RIGHT_CENTER,
|
egui::Align2::RIGHT_CENTER,
|
||||||
fmt::accounting(bal),
|
bal_text,
|
||||||
theme::semibold(13.5),
|
theme::semibold(13.5),
|
||||||
p.text,
|
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() {
|
if resp.clicked() {
|
||||||
go = Some(a.id);
|
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.add_space(12.0);
|
||||||
}
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(format!(
|
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;
|
let mut go = None;
|
||||||
widgets::card(ui, |ui| {
|
widgets::card(ui, |ui| {
|
||||||
let w = ui.available_width();
|
let amt = widgets::amount_col_w(ui.available_width(), 3, 90.0 + 120.0, 120.0);
|
||||||
let amt = 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 header = |ui: &mut Ui, vals: [&str; 5]| {
|
||||||
let (r, _) = ui.allocate_exact_size(vec2(w, 26.0), Sense::hover());
|
let (r, _) = ui.allocate_exact_size(vec2(w, 26.0), Sense::hover());
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
@@ -347,6 +372,7 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
if let Some(entry) = go {
|
if let Some(entry) = go {
|
||||||
shell.go(Route::Entry(entry));
|
shell.go(Route::Entry(entry));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
// One tile per cash and card account.
|
// One tile per cash and card account.
|
||||||
let mut tiles: Vec<(u64, bool)> = b.cash_accounts.iter().map(|a| (*a, false)).collect();
|
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)));
|
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 = tiles.len().clamp(1, 4);
|
||||||
|
let n = widgets::fit_columns(ui.available_width(), 200.0, 16.0, n);
|
||||||
for row in tiles.chunks(n) {
|
for row in tiles.chunks(n) {
|
||||||
columns(ui, 0.0, &vec![1.0; n], |i, ui| {
|
columns(ui, 0.0, &vec![1.0; n], |i, ui| {
|
||||||
let Some((id, is_card)) = row.get(i) else {
|
let Some((id, is_card)) = row.get(i) else {
|
||||||
|
|||||||
@@ -17,8 +17,16 @@ use rust_decimal::Decimal;
|
|||||||
|
|
||||||
fn kv(ui: &mut Ui, k: &str, v: &str) {
|
fn kv(ui: &mut Ui, k: &str, v: &str) {
|
||||||
let p = pal(ui.ctx());
|
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| {
|
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(
|
ui.painter().text(
|
||||||
r.left_center(),
|
r.left_center(),
|
||||||
egui::Align2::LEFT_CENTER,
|
egui::Align2::LEFT_CENTER,
|
||||||
@@ -79,7 +87,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
.all()
|
.all()
|
||||||
.filter(|a| a.account_type == ledger_core::AccountType::Equity)
|
.filter(|a| a.account_type == ledger_core::AccountType::Equity)
|
||||||
{
|
{
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(format!("{} {}", a.code, a.name))
|
RichText::new(format!("{} {}", a.code, a.name))
|
||||||
.font(theme::regular(13.0)),
|
.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 {
|
for m in &c.members {
|
||||||
let cap = b.ledger.chart().find_by_code(&m.capital_account);
|
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();
|
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)));
|
ui.label(RichText::new(&m.name).font(theme::semibold(13.5)));
|
||||||
widgets::pill(
|
widgets::pill(
|
||||||
ui,
|
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));
|
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 {
|
for per in periods {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
let (g, color) = match per.status {
|
let (g, color) = match per.status {
|
||||||
PeriodStatus::Closed => (icon::LOCK, p.muted),
|
PeriodStatus::Closed => (icon::LOCK, p.muted),
|
||||||
PeriodStatus::Open => (icon::LOCK_OPEN, p.warning),
|
PeriodStatus::Open => (icon::LOCK_OPEN, p.warning),
|
||||||
@@ -217,7 +225,7 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
} else {
|
} else {
|
||||||
(acc / dep).try_into().unwrap_or(0.0)
|
(acc / dep).try_into().unwrap_or(0.0)
|
||||||
};
|
};
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
if widgets::link(
|
if widgets::link(
|
||||||
ui,
|
ui,
|
||||||
RichText::new(&a.description).font(theme::semibold(13.5)),
|
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)
|
((total - remaining) / total).try_into().unwrap_or(0.0)
|
||||||
};
|
};
|
||||||
let due = s.due(b.today);
|
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(&s.description).font(theme::semibold(13.5)));
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(format!(
|
RichText::new(format!(
|
||||||
@@ -361,7 +369,7 @@ fn recurring(ui: &mut Ui, b: &Books) {
|
|||||||
.filter(|d| r.end.is_none_or(|e| *d <= e))
|
.filter(|d| r.end.is_none_or(|e| *d <= e))
|
||||||
})
|
})
|
||||||
.find(|d| !r.generated.contains(d));
|
.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(&r.description).font(theme::semibold(13.5)));
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(format!(
|
RichText::new(format!(
|
||||||
@@ -424,7 +432,7 @@ fn parties(ui: &mut Ui, b: &Books) {
|
|||||||
.find(|x| x.vendor_id == v.id)
|
.find(|x| x.vendor_id == v.id)
|
||||||
.map(|x| x.paid)
|
.map(|x| x.paid)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.label(RichText::new(&v.name).font(theme::semibold(13.0)));
|
ui.label(RichText::new(&v.name).font(theme::semibold(13.0)));
|
||||||
if let Some(f) = v.form_1099 {
|
if let Some(f) = v.form_1099 {
|
||||||
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
|
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)
|
.filter(|i| i.customer_id == c.id)
|
||||||
.map(|i| i.balance())
|
.map(|i| i.balance())
|
||||||
.sum();
|
.sum();
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.label(RichText::new(&c.name).font(theme::semibold(13.0)));
|
ui.label(RichText::new(&c.name).font(theme::semibold(13.0)));
|
||||||
ui.with_layout(
|
ui.with_layout(
|
||||||
egui::Layout::right_to_left(egui::Align::Center),
|
egui::Layout::right_to_left(egui::Align::Center),
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
|||||||
ui.add(
|
ui.add(
|
||||||
egui::TextEdit::singleline(&mut st.query)
|
egui::TextEdit::singleline(&mut st.query)
|
||||||
.hint_text(format!("{} Search file names", icon::MAGNIFYING_GLASS))
|
.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() {
|
if ui.selectable_label(st.category.is_none(), "All").clicked() {
|
||||||
st.category = None;
|
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", ""));
|
widgets::card(ui, |ui| widgets::empty(ui, icon::FILES, "No documents", ""));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let tile_w = 200.0;
|
// As many 200-point tiles as fit, stretched to fill the row.
|
||||||
let per_row = ((ui.available_width() + 16.0) / (tile_w + 16.0))
|
let per_row = widgets::fit_columns(ui.available_width(), 200.0, 16.0, usize::MAX);
|
||||||
.floor()
|
let tile_w =
|
||||||
.max(1.0) as usize;
|
((ui.available_width() - 16.0 * (per_row as f32 - 1.0)) / per_row as f32).min(280.0);
|
||||||
let mut go = None;
|
let mut go = None;
|
||||||
for row in docs.chunks(per_row) {
|
for row in docs.chunks(per_row) {
|
||||||
ui.horizontal_top(|ui| {
|
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))
|
format!("#{} {} ({})", x.id, x.description, fmt::dollars(x.amount))
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| format!("#{e}"));
|
.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));
|
shell.go(Route::Entry(e));
|
||||||
}
|
}
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
|||||||
@@ -110,14 +110,14 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
|||||||
ui.end_row();
|
ui.end_row();
|
||||||
if let Some(c) = &created {
|
if let Some(c) = &created {
|
||||||
k(ui, "Recorded");
|
k(ui, "Recorded");
|
||||||
ui.label(c);
|
ui.add(egui::Label::new(c).wrap());
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
}
|
}
|
||||||
if e.status == EntryStatus::Posted
|
if e.status == EntryStatus::Posted
|
||||||
&& let Some(t) = &posted
|
&& let Some(t) = &posted
|
||||||
{
|
{
|
||||||
k(ui, "Posted");
|
k(ui, "Posted");
|
||||||
ui.label(t);
|
ui.add(egui::Label::new(t).wrap());
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -126,7 +126,7 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
|||||||
// Lines.
|
// Lines.
|
||||||
section(ui, "Lines");
|
section(ui, "Lines");
|
||||||
let total_w = ui.available_width();
|
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
|
let class_names: std::collections::HashMap<u32, String> = b
|
||||||
.ledger
|
.ledger
|
||||||
.list_classes()
|
.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.allocate_ui(vec2(w, 22.0), |ui| {
|
||||||
ui.set_width(w);
|
ui.set_width(w);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let r = widgets::link(
|
let r = widgets::link_truncated(
|
||||||
ui,
|
ui,
|
||||||
RichText::new(b.account_label(line.account))
|
RichText::new(b.account_label(line.account))
|
||||||
.font(theme::regular(13.5)),
|
.font(theme::regular(13.5)),
|
||||||
|
|||||||
@@ -343,11 +343,11 @@ fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
let st = &mut shell.state.journal;
|
let st = &mut shell.state.journal;
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::TextEdit::singleline(&mut st.query)
|
egui::TextEdit::singleline(&mut st.query)
|
||||||
.hint_text(format!("{} Filter events", icon::MAGNIFYING_GLASS))
|
.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");
|
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),
|
egui::FontId::proportional(15.0),
|
||||||
p.muted,
|
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(
|
widgets::clipped_text(
|
||||||
&painter,
|
&painter,
|
||||||
egui::pos2(rect.left() + 80.0, y),
|
egui::pos2(rect.left() + 80.0, y),
|
||||||
&ev.text,
|
&ev.text,
|
||||||
theme::regular(13.0),
|
theme::regular(13.0),
|
||||||
p.text,
|
p.text,
|
||||||
rect.width() - 80.0 - 200.0,
|
rect.width() - 80.0 - ts_w,
|
||||||
);
|
|
||||||
painter.text(
|
|
||||||
egui::pos2(rect.right() - 8.0, y),
|
|
||||||
egui::Align2::RIGHT_CENTER,
|
|
||||||
fmt::timestamp(&line.ts),
|
|
||||||
theme::regular(12.0),
|
|
||||||
p.faint,
|
|
||||||
);
|
);
|
||||||
if resp.clicked() {
|
if resp.clicked() {
|
||||||
shell.go(Route::JournalEvent(line.seq));
|
shell.go(Route::JournalEvent(line.seq));
|
||||||
|
|||||||
@@ -18,19 +18,25 @@ use egui::{Margin, Ui};
|
|||||||
|
|
||||||
/// Standard page body: scrollable, padded.
|
/// Standard page body: scrollable, padded.
|
||||||
pub fn page(ui: &mut Ui, id: &str, add: impl FnOnce(&mut Ui)) {
|
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()
|
egui::ScrollArea::vertical()
|
||||||
.id_salt(id)
|
.id_salt(id)
|
||||||
|
.max_width(width)
|
||||||
.auto_shrink(false)
|
.auto_shrink(false)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.inner_margin(Margin {
|
.inner_margin(Margin {
|
||||||
left: 32,
|
left: side,
|
||||||
right: 32,
|
right: side,
|
||||||
top: 24,
|
top: 24,
|
||||||
bottom: 40,
|
bottom: 40,
|
||||||
})
|
})
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width((width - 2.0 * side as f32).max(0.0));
|
||||||
add(ui)
|
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);
|
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
|
/// 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>(
|
pub fn split<T: ?Sized>(
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -101,12 +136,25 @@ pub fn split<T: ?Sized>(
|
|||||||
) {
|
) {
|
||||||
let p = crate::theme::pal(ui.ctx());
|
let p = crate::theme::pal(ui.ctx());
|
||||||
let rect = ui.available_rect_before_wrap();
|
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 key = egui::Id::new(("split", id));
|
||||||
let mut detail_w: f32 = ui
|
let mut detail_w: f32 = ui
|
||||||
.ctx()
|
.ctx()
|
||||||
.data_mut(|d| d.get_persisted(key))
|
.data_mut(|d| d.get_persisted(key))
|
||||||
.unwrap_or(default_detail);
|
.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 divider_x = rect.right() - detail_w;
|
||||||
let left_rect = egui::Rect::from_min_max(rect.min, egui::pos2(divider_x, rect.bottom()));
|
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);
|
let right_rect = egui::Rect::from_min_max(egui::pos2(divider_x + 1.0, rect.top()), rect.max);
|
||||||
|
|||||||
@@ -223,11 +223,8 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
Route::Cash,
|
Route::Cash,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
let n = if ui.available_width() > 1180.0 { 6 } else { 3 };
|
super::grid(ui, tiles.len(), 150.0, 6, |i, ui| {
|
||||||
for row in tiles.chunks(n) {
|
let (tile, route) = &tiles[i];
|
||||||
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(
|
let r = widgets::kpi(
|
||||||
ui,
|
ui,
|
||||||
Kpi {
|
Kpi {
|
||||||
@@ -241,9 +238,7 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
if r.interact(egui::Sense::click()).clicked() {
|
if r.interact(egui::Sense::click()).clicked() {
|
||||||
shell.go(route.clone());
|
shell.go(route.clone());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `change` picks from the light palette; map to the current one.
|
/// `change` picks from the light palette; map to the current one.
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
}
|
}
|
||||||
ui.add_space(10.0);
|
ui.add_space(10.0);
|
||||||
// The same numbers as a table, with the margin.
|
// The same numbers as a table, with the margin.
|
||||||
let w = ui.available_width();
|
let cw = widgets::amount_col_w(ui.available_width(), 4, 100.0, 130.0);
|
||||||
let cw = 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 header = |ui: &mut Ui, cells: [&str; 5]| {
|
||||||
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
|
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
@@ -152,6 +153,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
|
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
ui.add_space(16.0);
|
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", "");
|
widgets::empty(ui, icon::RECEIPT, "No spending yet", "");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let w = ui.available_width();
|
let label_min = 180.0;
|
||||||
let cw = 130.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());
|
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
|
||||||
for (i, c) in ["This month", "Last month", "3-month avg"]
|
for (i, c) in ["This month", "Last month", "3-month avg"]
|
||||||
.iter()
|
.iter()
|
||||||
@@ -237,12 +241,13 @@ fn spending_changes(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
if resp.hovered() {
|
if resp.hovered() {
|
||||||
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
|
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),
|
r.left_center() + vec2(4.0, 0.0),
|
||||||
egui::Align2::LEFT_CENTER,
|
&format!("{code} {name}"),
|
||||||
format!("{code} {name}"),
|
|
||||||
theme::regular(13.0),
|
theme::regular(13.0),
|
||||||
p.text,
|
p.text,
|
||||||
|
w - cw * 3.0 - 8.0,
|
||||||
);
|
);
|
||||||
// Flag a category already running well above its average.
|
// Flag a category already running well above its average.
|
||||||
let hot = *avg > Decimal::ZERO && *now > *avg * Decimal::new(125, 2);
|
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);
|
go = b.ledger.chart().find_by_code(code).map(|a| a.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if let Some(id) = go {
|
if let Some(id) = go {
|
||||||
|
|||||||
@@ -859,23 +859,8 @@ fn aging(rows: Vec<ledger_core::AgingRow>, as_of: NaiveDate) -> Table {
|
|||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
// The page.
|
// The page.
|
||||||
|
|
||||||
pub fn show(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) {
|
/// The reports, grouped as the side list shows them.
|
||||||
let p = pal(ui.ctx());
|
const GROUPS: [(&str, &[ReportKind]); 3] = [
|
||||||
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] = [
|
|
||||||
(
|
(
|
||||||
"Statements",
|
"Statements",
|
||||||
&[
|
&[
|
||||||
@@ -898,8 +883,33 @@ pub fn show(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) {
|
|||||||
ReportKind::ApAging,
|
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.add_space(10.0);
|
||||||
nav.label(RichText::new(g).font(theme::regular(12.0)).color(p.muted));
|
nav.label(RichText::new(g).font(theme::regular(12.0)).color(p.muted));
|
||||||
nav.add_space(2.0);
|
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 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));
|
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body));
|
||||||
child.set_clip_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());
|
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 p = pal(ui.ctx());
|
||||||
let point_in_time = matches!(
|
let point_in_time = matches!(
|
||||||
kind,
|
kind,
|
||||||
@@ -961,8 +971,43 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) {
|
|||||||
);
|
);
|
||||||
let st = &mut shell.state.reports;
|
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| {
|
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")
|
egui::ComboBox::from_id_salt("range")
|
||||||
.selected_text(RichText::new(format!(
|
.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 {
|
if let Range::Custom(s, e) = st.range {
|
||||||
|
room(ui, 250.0);
|
||||||
let (mut js, mut je) = (to_jiff(s), to_jiff(e));
|
let (mut js, mut je) = (to_jiff(s), to_jiff(e));
|
||||||
ui.add(
|
ui.add(
|
||||||
egui_extras::DatePickerButton::new(&mut js)
|
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));
|
st.range = Range::Custom(s.min(e), e.max(s));
|
||||||
}
|
}
|
||||||
if splittable {
|
if splittable {
|
||||||
ui.add_space(8.0);
|
room(ui, 270.0);
|
||||||
widgets::segmented(
|
widgets::segmented(
|
||||||
ui,
|
ui,
|
||||||
&mut st.columns,
|
&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) {
|
if comparable && (st.columns == Columns::Total || !splittable) {
|
||||||
ui.add_space(8.0);
|
room(ui, 160.0);
|
||||||
egui::ComboBox::from_id_salt("compare")
|
egui::ComboBox::from_id_salt("compare")
|
||||||
.selected_text(match st.compare {
|
.selected_text(match st.compare {
|
||||||
Compare::None => "No comparison",
|
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 {
|
if let Some(t) = table {
|
||||||
render(ui, &t, shell);
|
render(ui, &t, shell);
|
||||||
}
|
}
|
||||||
|
if let Some(k) = pick {
|
||||||
|
shell.go(Route::Reports(k));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(ui: &mut Ui, t: &Table, shell: &mut Shell<'_>) {
|
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());
|
ui.ctx().copy_text(tsv.clone());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let col_w: f32 = if t.columns.len() > 6 { 112.0 } else { 136.0 };
|
let n = t.columns.len().max(1) as f32;
|
||||||
let cols_w = col_w * t.columns.len() 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()
|
egui::ScrollArea::horizontal()
|
||||||
.id_salt("report_table")
|
.id_salt("report_table")
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
// Labels get what's left, but never so much that the eye loses
|
// Labels get what's left, but never so much that the eye loses
|
||||||
// the line between a label and its amount.
|
// 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;
|
let width = label_w + cols_w;
|
||||||
ui.set_min_width(width);
|
ui.set_min_width(width);
|
||||||
let (hr, _) = ui.allocate_exact_size(vec2(width, 30.0), Sense::hover());
|
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,
|
_ if !r.detail.is_empty() => rr.center().y - 7.0,
|
||||||
_ => rr.center().y,
|
_ => rr.center().y,
|
||||||
};
|
};
|
||||||
let label_g = ui
|
// Labels stop short of the first amount column (notes,
|
||||||
.painter()
|
// which have none, run the full width).
|
||||||
.layout_no_wrap(r.label.clone(), font.clone(), color);
|
let label_room = if r.cells.is_empty() {
|
||||||
ui.painter().galley(
|
width - indent - 8.0
|
||||||
egui::pos2(rr.left() + indent + 4.0, y - label_g.size().y / 2.0),
|
} else {
|
||||||
label_g,
|
label_w - indent - 12.0
|
||||||
|
};
|
||||||
|
widgets::clipped_text(
|
||||||
|
ui.painter(),
|
||||||
|
egui::pos2(rr.left() + indent + 4.0, y),
|
||||||
|
&r.label,
|
||||||
|
font.clone(),
|
||||||
color,
|
color,
|
||||||
|
label_room,
|
||||||
);
|
);
|
||||||
if !r.detail.is_empty() {
|
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::pos2(rr.left() + indent + 4.0, rr.center().y + 9.0),
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
&r.detail,
|
&r.detail,
|
||||||
theme::regular(11.5),
|
theme::regular(11.5),
|
||||||
p.faint,
|
p.faint,
|
||||||
|
label_room,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (i, c) in r.cells.iter().enumerate() {
|
for (i, c) in r.cells.iter().enumerate() {
|
||||||
|
|||||||
@@ -162,38 +162,10 @@ fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> eg
|
|||||||
theme::semibold(16.0),
|
theme::semibold(16.0),
|
||||||
p.text,
|
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 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 {
|
let (amount, color) = if e.cash_effect > rust_decimal::Decimal::ZERO {
|
||||||
(format!("+{}", fmt::money(e.cash_effect)), p.positive)
|
(format!("+{}", fmt::money(e.cash_effect)), p.positive)
|
||||||
} else if e.cash_effect < rust_decimal::Decimal::ZERO {
|
} 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 {
|
} else {
|
||||||
(fmt::money(e.amount), p.muted)
|
(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(
|
painter.text(
|
||||||
egui::pos2(right, rect.center().y - 6.0),
|
egui::pos2(right, rect.center().y - 6.0),
|
||||||
egui::Align2::RIGHT_CENTER,
|
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 {
|
if let Some((g, c)) = clip {
|
||||||
painter.text(
|
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,
|
egui::Align2::CENTER_CENTER,
|
||||||
g,
|
g,
|
||||||
egui::FontId::proportional(16.0),
|
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
|
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) {
|
fn filters(ui: &mut Ui, b: &Books, st: &mut State) {
|
||||||
widgets::segmented(
|
let options = [
|
||||||
ui,
|
|
||||||
&mut st.filter,
|
|
||||||
&[
|
|
||||||
(Filter::All, "All"),
|
(Filter::All, "All"),
|
||||||
(Filter::Posted, "Posted"),
|
(Filter::Posted, "Posted"),
|
||||||
(Filter::Drafts, "Drafts"),
|
(Filter::Drafts, "Drafts"),
|
||||||
(Filter::WithReceipt, "With receipt"),
|
(Filter::WithReceipt, "With receipt"),
|
||||||
(Filter::NoReceipt, "No 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 {
|
if let Some(a) = st.account {
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
let label = format!("{} {} {}", icon::FUNNEL, b.account_label(a), icon::X);
|
let label = format!("{} {} {}", icon::FUNNEL, b.account_label(a), icon::X);
|
||||||
|
|||||||
@@ -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.
|
/// Text that looks like plain text until you hover it, then like a link.
|
||||||
pub fn link(ui: &mut Ui, text: RichText) -> Response {
|
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 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() {
|
if r.hovered() {
|
||||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||||
let rect = r.rect;
|
let rect = r.rect;
|
||||||
@@ -163,7 +172,15 @@ pub fn kpi(ui: &mut Ui, k: Kpi<'_>) -> Response {
|
|||||||
.color(p.muted),
|
.color(p.muted),
|
||||||
);
|
);
|
||||||
ui.add_space(4.0);
|
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 {
|
if let Some((text, color)) = &k.sub {
|
||||||
ui.add_space(2.0);
|
ui.add_space(2.0);
|
||||||
ui.label(RichText::new(text).font(theme::regular(12.0)).color(*color));
|
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;
|
let y = pos.y - galley.size().y / 2.0;
|
||||||
painter.galley(egui::pos2(pos.x, y), galley, color);
|
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))
|
||||||
|
}
|
||||||
@@ -40,12 +40,12 @@ fn screenshots() {
|
|||||||
};
|
};
|
||||||
let only = std::env::var("SHOT_ONLY").ok();
|
let only = std::env::var("SHOT_ONLY").ok();
|
||||||
let dark = std::env::var("SHOT_DARK").is_ok();
|
let dark = std::env::var("SHOT_DARK").is_ok();
|
||||||
let first_entry = data::load_once(&source)
|
let loaded = data::load_once(&source).unwrap();
|
||||||
.unwrap()
|
let first_entry = loaded.entries.last().map(|e| e.id).unwrap_or(1);
|
||||||
.entries
|
let first_cash = loaded.cash_accounts.first().copied().unwrap_or(1);
|
||||||
.last()
|
let last_doc = loaded.attachments.last().map(|a| a.id).unwrap_or(1);
|
||||||
.map(|e| e.id)
|
let last_event = loaded.seq();
|
||||||
.unwrap_or(1);
|
drop(loaded);
|
||||||
let routes: Vec<(&str, Route)> = vec![
|
let routes: Vec<(&str, Route)> = vec![
|
||||||
("overview", Route::Overview),
|
("overview", Route::Overview),
|
||||||
("transactions", Route::Transactions),
|
("transactions", Route::Transactions),
|
||||||
@@ -62,7 +62,16 @@ fn screenshots() {
|
|||||||
("profit", Route::Profit),
|
("profit", Route::Profit),
|
||||||
("pnl", Route::Reports(ReportKind::ProfitAndLoss)),
|
("pnl", Route::Reports(ReportKind::ProfitAndLoss)),
|
||||||
("balance", Route::Reports(ReportKind::BalanceSheet)),
|
("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),
|
("accounts", Route::Accounts),
|
||||||
|
("account", Route::Account(first_cash)),
|
||||||
|
("document", Route::Document(last_doc)),
|
||||||
|
("event", Route::JournalEvent(last_event)),
|
||||||
("documents", Route::Documents),
|
("documents", Route::Documents),
|
||||||
("company", Route::Company),
|
("company", Route::Company),
|
||||||
("journal", Route::Journal),
|
("journal", Route::Journal),
|
||||||
|
|||||||
Reference in new issue
Block a user