Every crumb above the page you're on links to where it names: the section goes to its home (Parties › Stephen Waits → Parties), and a reconciliation's trail runs Accounts › its account › it. Same look; a pointer and "Go to …" on hover. A crumb naming the current page isn't a link. 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
a44a8873df
commit
579ab743d4
3 files changed
+184
-25
No files matched your search
@@ -627,33 +627,46 @@ impl App {
|
||||
}
|
||||
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 {
|
||||
let crumbs =
|
||||
crate::palette::crumbs(self.books.as_deref(), &self.nav.current);
|
||||
// Narrow, the last crumb alone says where you are.
|
||||
let skip = if ui.available_width() >= 260.0 {
|
||||
0
|
||||
} else {
|
||||
crumbs.len() - 1
|
||||
};
|
||||
let last = crumbs.len() - 1;
|
||||
let mut go = None;
|
||||
for (i, (text, to)) in crumbs.iter().enumerate().skip(skip) {
|
||||
if i > skip && 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(),
|
||||
);
|
||||
let rich = if i == last && i > 0 {
|
||||
RichText::new(text).font(theme::semibold(13.5))
|
||||
} else {
|
||||
RichText::new(text)
|
||||
.font(theme::regular(13.5))
|
||||
.color(p.muted)
|
||||
};
|
||||
let label = egui::Label::new(rich).truncate();
|
||||
match to {
|
||||
Some(to) => {
|
||||
let r = ui.add(label.sense(egui::Sense::click()));
|
||||
if r.hovered() {
|
||||
ui.ctx()
|
||||
.set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
if r.on_hover_text(format!("Go to {text}")).clicked() {
|
||||
go = Some(to.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.add(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(to) = go {
|
||||
self.nav.go(to);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -458,6 +458,45 @@ pub fn search(b: &Books, query: &str) -> Vec<Item> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The breadcrumb for a route, outermost first: its section, any page it
|
||||
/// sits under (a reconciliation under its account), then the route
|
||||
/// itself. Each crumb is its text and where clicking it goes; `None` for
|
||||
/// the page you're on, which isn't a link.
|
||||
///
|
||||
/// ```text
|
||||
/// INVARIANTS:
|
||||
/// 1. The first crumb is the section's title, going to Section::home.
|
||||
/// 2. The last crumb is the current page: describe(r) when it says
|
||||
/// anything, else the section itself (a section's own page).
|
||||
/// 3. Only the last crumb is None; a crumb whose route is the current
|
||||
/// one is never a link.
|
||||
/// ```
|
||||
pub fn crumbs(b: Option<&Books>, r: &Route) -> Vec<(String, Option<Route>)> {
|
||||
let section = r.section();
|
||||
let here = b.map(|b| describe(b, r)).unwrap_or_default();
|
||||
let mut out = vec![(section.title().to_string(), Some(section.home()))];
|
||||
if let (Some(b), Route::Reconciliation(id)) = (b, r)
|
||||
&& let Some(rec) = b.reconciliation(*id)
|
||||
{
|
||||
let account = Route::Account(rec.account_id);
|
||||
out.push((describe(b, &account), Some(account)));
|
||||
}
|
||||
if here.is_empty() {
|
||||
out.truncate(1);
|
||||
} else {
|
||||
out.push((here, None));
|
||||
}
|
||||
for c in &mut out {
|
||||
if c.1.as_ref() == Some(r) {
|
||||
c.1 = None;
|
||||
}
|
||||
}
|
||||
if let Some(last) = out.last_mut() {
|
||||
last.1 = None;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A short description of a route for the breadcrumb.
|
||||
pub fn describe(b: &Books, r: &Route) -> String {
|
||||
match r {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! The breadcrumb links back up: every crumb but the page you're on goes
|
||||
//! where it names.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: from Parties › Stephen Waits, clicking "Parties" goes back to the
|
||||
//! directory; deeper trails link each level.
|
||||
//! SOURCE: DECISION (Stephen, 2026-10-16: "I should be able to click
|
||||
//! parties and it takes me back there ... when it goes deeper").
|
||||
//! INVARIANTS: palette::crumbs 1-3, and
|
||||
//! 4. Clicking a crumb goes to its route (a history step, so Back
|
||||
//! returns).
|
||||
//! ```
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use ledger_gui::nav::{ReportKind, Route, Section};
|
||||
use ledger_gui::palette::{crumbs, describe};
|
||||
use ledger_gui::{App, Source, data};
|
||||
|
||||
fn demo(tag: &str) -> Source {
|
||||
let dir = std::env::temp_dir().join(format!("ledger-gui-crumbs-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
|
||||
ledger_gui::demo::build(&dir, today).unwrap();
|
||||
Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: Some(dir.join("wanderbooks.toml")),
|
||||
today: Some(today),
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANTS 1-3 on every kind of page in the demo books.
|
||||
#[test]
|
||||
fn crumbs_link_every_level_but_the_last() {
|
||||
let b = data::load_once(&demo("unit")).unwrap();
|
||||
let party = b.ledger.parties().next().unwrap().id;
|
||||
let rec = b.reconciliations.first().expect("the demo reconciles");
|
||||
let entry = b.entries.first().unwrap().id;
|
||||
let mut routes = vec![
|
||||
Route::Party(party),
|
||||
Route::Reconciliation(rec.id),
|
||||
Route::Entry(entry),
|
||||
Route::Reports(ReportKind::ProfitAndLoss),
|
||||
Route::Reports(ReportKind::BalanceSheet),
|
||||
];
|
||||
routes.extend(Section::ALL.iter().map(|s| s.home()));
|
||||
for r in &routes {
|
||||
let c = crumbs(Some(&b), r);
|
||||
assert_eq!(c[0].0, r.section().title(), "{r:?}");
|
||||
assert!(c.last().unwrap().1.is_none(), "{r:?}: the page you're on");
|
||||
for (_, to) in &c {
|
||||
assert_ne!(to.as_ref(), Some(r), "{r:?} links to itself");
|
||||
}
|
||||
// Every crumb above is a link, except one naming this very page
|
||||
// (Reports on Profit and loss, the Reports section's home).
|
||||
for (i, (_, to)) in c[..c.len() - 1].iter().enumerate() {
|
||||
let home_here = i == 0 && r.section().home() == *r;
|
||||
assert_eq!(to.is_some(), !home_here, "{r:?}: crumb {i}");
|
||||
}
|
||||
let here = describe(&b, r);
|
||||
if !here.is_empty() {
|
||||
assert_eq!(c.last().unwrap().0, here);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
crumbs(Some(&b), &Route::Party(party)),
|
||||
[
|
||||
("Parties".to_string(), Some(Route::Parties)),
|
||||
(b.party_label(party), None),
|
||||
]
|
||||
);
|
||||
// A reconciliation sits under its account.
|
||||
let account = Route::Account(rec.account_id);
|
||||
let c = crumbs(Some(&b), &Route::Reconciliation(rec.id));
|
||||
assert_eq!(c.len(), 3);
|
||||
assert_eq!(c[0].1, Some(Route::Accounts));
|
||||
assert_eq!(c[1], (describe(&b, &account), Some(account)));
|
||||
// A section's own page is one crumb, not a link.
|
||||
assert_eq!(
|
||||
crumbs(Some(&b), &Route::Parties),
|
||||
[("Parties".to_string(), None)]
|
||||
);
|
||||
// P&L is the Reports section's home, so "Reports" there isn't a link.
|
||||
assert_eq!(
|
||||
crumbs(Some(&b), &Route::Reports(ReportKind::ProfitAndLoss))[0].1,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 4, through the top bar.
|
||||
#[test]
|
||||
fn clicking_a_crumb_goes_there() {
|
||||
let source = demo("click");
|
||||
let b = data::load_once(&source).unwrap();
|
||||
let rec = b.reconciliations.first().unwrap().clone();
|
||||
let account = describe(&b, &Route::Account(rec.account_id));
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1280.0, 900.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Reconciliation(rec.id)));
|
||||
h.run_steps(3);
|
||||
h.get_all_by_label(&account).next().unwrap().click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().route(), &Route::Account(rec.account_id));
|
||||
h.get_by_label("Accounts").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().route(), &Route::Accounts);
|
||||
}
|
||||
Reference in new issue
Block a user