From 579ab743d4ff7b8887298f4f96b8f1c376184190 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 16 Oct 2026 08:41:39 -0600 Subject: [PATCH] Viewer: clickable breadcrumbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-gui/src/app.rs | 63 +++++++++------ crates/ledger-gui/src/palette.rs | 39 +++++++++ crates/ledger-gui/tests/breadcrumbs.rs | 107 +++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 25 deletions(-) create mode 100644 crates/ledger-gui/tests/breadcrumbs.rs diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index be8f424..6b75d19 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -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); } }); }); diff --git a/crates/ledger-gui/src/palette.rs b/crates/ledger-gui/src/palette.rs index fee2176..4099bc8 100644 --- a/crates/ledger-gui/src/palette.rs +++ b/crates/ledger-gui/src/palette.rs @@ -458,6 +458,45 @@ pub fn search(b: &Books, query: &str) -> Vec { 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)> { + 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 { diff --git a/crates/ledger-gui/tests/breadcrumbs.rs b/crates/ledger-gui/tests/breadcrumbs.rs new file mode 100644 index 0000000..81b2382 --- /dev/null +++ b/crates/ledger-gui/tests/breadcrumbs.rs @@ -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); +}