From 641184d3a3ae2d4ae6d424b0c8b023e50da00bab Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 22 Oct 2026 10:29:08 -0600 Subject: [PATCH] viewer: the sidebar's links fit short windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's viewer at 1479x519 (scale 1) showed Overview through half of Accounts above the status card; Assets, Parties, Projects, Documents, Company and Journal sat in a scroll area with no sign that it scrolled, so the links seemed to go behind the status box. The sidebar now gives way in steps, each only when the one before is not enough, so a window where everything fit looks exactly as it did (pixel-checked against main at 1440x920, tests/goldens): 1. The status card folds to its one summary line, "● Live · event 559 · ✓ verified". Hovering it shows the three checks; right-click keeps the card's copy menu. The line is the fullest wording that fits the card (summaries), never cut off: a failed check is named ("⊘ Database differs", "⊘ 2 files damaged") before the event and the word "Live" are kept, and only then shortened to a count. Whatever the line leaves out, its tooltip says. 2. The rows tighten: the footer's menus, the gap under the header, the spacing between links, then the rows' height, down to 24 points, in the same font. The rail tightens the same way, also to 24 points. 3. Only then do the links scroll, with a soft fade and a chevron at each clipped edge (the rail's chevron sits centred on its own pill, clear of the icons), and the current page's link is scrolled into view whenever the page changes. Links that overrun their room by under half a pixel count as fitting, so the card folds where main's links began to lose a visible sliver, not a point sooner at fractional scales. At 1479x519 and scale 1 all twelve links show unscrolled in the sidebar, and at 900x519 in the rail, in every footer state (Live, Snapshot, each database state, damaged files, no Books menu). Tests (tests/sidenav.rs, fixture books with a footer like the live one): every height from 300 to 920 px at scales 1, 1.25, 1.5 and 2, sidebar and rail, for each footer state: no link under the footer, links cut off only when they scroll, and nothing scrolls from 519 px up at scale 1; the owner's window in sidebar and rail; the folded line fits its card and names a failed check, its tooltip the three checks; the scroll cue and scrolling to the current link; the tall sidebar unchanged. Unit tests for fit, its order of steps, the footer's memo and the folded line's wordings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- crates/ledger-gui/src/app.rs | 918 +++++++++++++++--- crates/ledger-gui/src/menu.rs | 2 +- crates/ledger-gui/src/owner.rs | 2 +- crates/ledger-gui/tests/edges.rs | 9 +- .../tests/goldens/sidenav-1440x920.png | Bin 0 -> 43860 bytes .../tests/goldens/sidenav-1440x920.shapes | 1 + crates/ledger-gui/tests/sidenav.rs | 758 +++++++++++++++ 7 files changed, 1535 insertions(+), 155 deletions(-) create mode 100644 crates/ledger-gui/tests/goldens/sidenav-1440x920.png create mode 100644 crates/ledger-gui/tests/goldens/sidenav-1440x920.shapes create mode 100644 crates/ledger-gui/tests/sidenav.rs diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 26bb452..4d46e39 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -284,7 +284,7 @@ impl App { }; let mut button = egui::Button::new(text).frame(false); if compact { - button = button.min_size(vec2(32.0, 32.0)); + button = button.min_size(vec2(32.0, ui.spacing().interact_size.y + 4.0)); } // Open while the filter changes; closed when a scheme is chosen. let config = egui::containers::menu::MenuConfig::new() @@ -605,90 +605,91 @@ impl App { ); }); }); - ui.add_space(18.0); let current = self.nav.current.section(); let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0); - // Short windows: the links scroll above the status box - // instead of the box drawing over them. The box's height - // is the one it was drawn at last frame. - let footer_id = egui::Id::new("sidebar_footer_h"); - let footer_h = ui - .ctx() - .data(|d| d.get_temp::(footer_id)) - .unwrap_or(SIDEBAR_FOOTER); + // Short windows give way in steps (Fit): the status card + // folds, then the rows tighten, then the links scroll. + // The footer's heights are the ones it was drawn at. + let fold = 3.0 + * (ui.ctx().fonts_mut(|f| f.row_height(&theme::regular(11.5))) + + ui.spacing().item_spacing.y); + let memo = FooterMemo::load(ui, "sidebar"); + let heights = memo.heights(SIDEBAR_FOOTER, fold, SIDEBAR_TIGHTEN); + let fit = fit(ui.available_height(), heights, &SIDEBAR_ROWS, slack(ui)); + ui.add_space(if fit.tight { + HEAD_GAP_TIGHT + } else { + SIDEBAR_ROWS.head_gap + }); + let footer_h = heights[fit.form()]; // At least one link always shows; below that the footer // gives way (footer_layout). - let links_h = (ui.available_height() - footer_h - FOOTER_GAP).max(32.0); - egui::ScrollArea::vertical() - .id_salt("sidebar_links") - .max_height(links_h) - .show(ui, |ui| { - for s in Section::ALL { - let on = s == current; - let (rect, resp) = ui.allocate_exact_size( - vec2(ui.available_width(), 32.0), - Sense::click(), - ); - // Named for assistive tech (and the tests). - resp.widget_info(|| { - egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s.title()) - }); - 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); - } - let color = if on { p.text } else { p.muted }; - ui.painter().text( - rect.left_center() + vec2(10.0, 0.0), - egui::Align2::LEFT_CENTER, - s.icon(), - egui::FontId::proportional(16.0), - if on { p.accent } else { p.muted }, - ); - ui.painter().text( - rect.left_center() + vec2(36.0, 0.0), - egui::Align2::LEFT_CENTER, - s.title(), - if on { - theme::semibold(13.5) - } else { - theme::regular(13.5) - }, - color, - ); - if s == Section::Overview && attention > 0 { - let badge = egui::Rect::from_center_size( - rect.right_center() - vec2(18.0, 0.0), - vec2(22.0, 18.0), - ); - ui.painter().rect_filled( - badge, - CornerRadius::same(9), - p.warning_soft, - ); - ui.painter().text( - badge.center(), - egui::Align2::CENTER_CENTER, - attention.to_string(), - theme::semibold(11.0), - p.warning, - ); - } - if resp.clicked() { - self.nav.go(s.home()); - } - } + let links_h = (ui.available_height() - footer_h - FOOTER_GAP).max(fit.row); + let nav = &mut self.nav; + links(ui, "sidebar", fit, links_h, current, |ui, s| { + let on = s == current; + let (rect, resp) = + ui.allocate_exact_size(vec2(ui.available_width(), fit.row), Sense::click()); + // Named for assistive tech (and the tests). + resp.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s.title()) }); + 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); + } + let color = if on { p.text } else { p.muted }; + ui.painter().text( + rect.left_center() + vec2(10.0, 0.0), + egui::Align2::LEFT_CENTER, + s.icon(), + egui::FontId::proportional(16.0), + if on { p.accent } else { p.muted }, + ); + ui.painter().text( + rect.left_center() + vec2(36.0, 0.0), + egui::Align2::LEFT_CENTER, + s.title(), + if on { + theme::semibold(13.5) + } else { + theme::regular(13.5) + }, + color, + ); + if s == Section::Overview && attention > 0 { + let badge = egui::Rect::from_center_size( + rect.right_center() - vec2(18.0, 0.0), + vec2(22.0, 18.0), + ); + ui.painter() + .rect_filled(badge, CornerRadius::same(9), p.warning_soft); + ui.painter().text( + badge.center(), + egui::Align2::CENTER_CENTER, + attention.to_string(), + theme::semibold(11.0), + p.warning, + ); + } + if resp.clicked() { + nav.go(s.home()); + } + resp + }); let footer = footer_layout(ui, footer_h, Align::Min, |ui| { + if fit.tight { + tighten(ui); + } self.scheme_menu(ui, false); if let Some(b) = &books { ui.add_space(2.0); @@ -702,10 +703,11 @@ impl App { b, ui.input(|i| i.time) - self.updated_at, self.loader.is_some() || self.live_label, + fit.folded, ); } }); - remember_footer(ui, footer_id, footer.response.rect.height(), footer_h); + memo.measured(ui, "sidebar", fit.form(), footer.response.rect); }); } @@ -740,64 +742,64 @@ impl App { ); 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); - let footer_id = egui::Id::new("rail_footer_h"); - let footer_h = ui - .ctx() - .data(|d| d.get_temp::(footer_id)) - .unwrap_or(RAIL_FOOTER); - let links_h = (ui.available_height() - footer_h - FOOTER_GAP).max(36.0); - egui::ScrollArea::vertical() - .id_salt("rail_links") - .max_height(links_h) - .show(ui, |ui| { - for s in Section::ALL { - let on = s == current; - let (rect, resp) = ui.allocate_exact_size( - vec2(ui.available_width(), 36.0), - Sense::click(), - ); - resp.widget_info(|| { - egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s.title()) - }); - 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()); - } - } + // As the sidebar's, with no card to fold. + let memo = FooterMemo::load(ui, "rail"); + let heights = memo.heights(RAIL_FOOTER, 0.0, RAIL_TIGHTEN); + let fit = fit(ui.available_height(), heights, &RAIL_ROWS, slack(ui)); + ui.add_space(if fit.tight { + HEAD_GAP_TIGHT + } else { + RAIL_ROWS.head_gap + }); + let footer_h = heights[fit.form()]; + let links_h = (ui.available_height() - footer_h - FOOTER_GAP).max(fit.row); + let nav = &mut self.nav; + links(ui, "rail", fit, links_h, current, |ui, s| { + let on = s == current; + let (rect, resp) = + ui.allocate_exact_size(vec2(ui.available_width(), fit.row), Sense::click()); + resp.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s.title()) }); + 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() + }; + let resp = resp.on_hover_text(tip); + if resp.clicked() { + nav.go(s.home()); + } + resp + }); let footer = footer_layout(ui, footer_h, Align::Center, |ui| { + if fit.tight { + tighten(ui); + } self.scheme_menu(ui, true); if let Some(b) = &books { self.menu.ui(ui, b, true); @@ -808,7 +810,10 @@ impl App { 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()); + let (r, resp) = ui.allocate_exact_size( + vec2(32.0, ui.spacing().interact_size.y + 4.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( @@ -843,7 +848,7 @@ impl App { resp.on_hover_text(tip); } }); - remember_footer(ui, footer_id, footer.response.rect.height(), footer_h); + memo.measured(ui, "rail", fit.form(), footer.response.rect); }); } @@ -977,18 +982,360 @@ impl App { /// Width of the icon rail. const RAIL_W: f32 = 60.0; -/// Height kept clear under the sidebar's links for the status box and the -/// theme toggle before the footer is first measured; the rail's -/// equivalent. After the first frame the footer's drawn height is used -/// ([`remember_footer`]). +/// The footer's height (the status box and the menus under the sidebar's +/// links; the rail's equivalent) before it is first measured. After the +/// first frame its drawn heights are used ([`FooterMemo`]). const SIDEBAR_FOOTER: f32 = 172.0; const RAIL_FOOTER: f32 = 84.0; +/// How much shorter the footer is tightened ([`tighten`]), before it is +/// measured so. +const SIDEBAR_TIGHTEN: f32 = 36.0; +const RAIL_TIGHTEN: f32 = 36.0; /// Space kept between the last link and the footer. const FOOTER_GAP: f32 = 8.0; -/// Keep the footer's drawn height for the next frame's links, and draw -/// again at once when it changed (the first frame, a check appearing). +/// Tightened: the space between links, and under the header. +const GAP_MIN: f32 = 2.0; +const HEAD_GAP_TIGHT: f32 = 8.0; + +/// A panel's links: how many, their height and spacing, the least height +/// they tighten to, the gap above them, and whether its status card folds. +struct Rows { + n: usize, + row: f32, + gap: f32, + row_min: f32, + head_gap: f32, + foldable: bool, +} + +impl Rows { + fn height(&self, row: f32, gap: f32) -> f32 { + let n = self.n as f32; + n * row + (n - 1.0) * gap + } +} + +const SIDEBAR_ROWS: Rows = Rows { + n: Section::ALL.len(), + row: 32.0, + gap: 6.0, + row_min: 24.0, + head_gap: 18.0, + foldable: true, +}; + +const RAIL_ROWS: Rows = Rows { + n: Section::ALL.len(), + row: 36.0, + gap: 6.0, + // As low as the sidebar's: at 28 the rail scrolled in the owner's + // window (review, 2026-10-22). + row_min: 24.0, + head_gap: 18.0, + foldable: false, +}; + +/// How a panel's links and footer share the window's height (owner, +/// 2026-10-22: at 1479x519 half the links hid behind the status box). +/// Each step is taken only when the one before isn't enough, so where +/// everything fits nothing changes. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Fit { + /// 1. The status card folded to its summary line. + folded: bool, + /// 2. The rows tightened: the footer's menus, the gap under the + /// header, and the links' spacing, then their height. + tight: bool, + row: f32, + gap: f32, + /// 3. Even tight they don't fit: the links scroll. + scroll: bool, +} + +impl Fit { + /// Which of the footer's three forms it draws: full, folded, tight. + fn form(&self) -> usize { + if self.tight { + 2 + } else if self.folded { + 1 + } else { + 0 + } + } +} + +/// The fit for `avail` points under the header, the footer being +/// `footer` high in each form ([`Fit::form`]), overrunning by `slack` +/// points ([`slack`]) taken as fitting. +fn fit(avail: f32, footer: [f32; 3], g: &Rows, slack: f32) -> Fit { + let full = g.height(g.row, g.gap); + let at_ease = Fit { + folded: false, + tight: false, + row: g.row, + gap: g.gap, + scroll: false, + }; + // The links fit when what they overrun is under half a pixel + // (`slack`, points), which no one sees: at 1.25 the rows' sum runs a + // hair over whole points, and without it the card folded where main + // drew everything (review, 2026-10-22). + let fits = |footer: f32| avail - g.head_gap - FOOTER_GAP - footer + slack >= full; + if fits(footer[0]) { + return at_ease; + } + if g.foldable && fits(footer[1]) { + return Fit { + folded: true, + ..at_ease + }; + } + let tight = Fit { + folded: g.foldable, + tight: true, + ..at_ease + }; + let room = avail - HEAD_GAP_TIGHT - FOOTER_GAP - footer[2]; + let n = g.n as f32; + // The space between the links gives first, then the links. + let gap = ((room - n * g.row) / (n - 1.0)).floor(); + if gap >= GAP_MIN { + return Fit { + gap: gap.min(g.gap), + ..tight + }; + } + let row = ((room - (n - 1.0) * GAP_MIN) / n).floor(); + if row >= g.row_min { + return Fit { + row: row.min(g.row), + gap: GAP_MIN, + ..tight + }; + } + Fit { + row: g.row_min, + gap: GAP_MIN, + scroll: true, + ..tight + } +} + +/// Half a pixel, in points: links overrunning their room by less are +/// drawn whole, with no cue. +fn slack(ui: &Ui) -> f32 { + 0.5 / ui.ctx().pixels_per_point() +} + +/// The footer's height in each form, as last drawn; a form not drawn yet +/// is estimated from one that was. +#[derive(Clone, Copy, Debug, Default)] +struct FooterMemo([Option; 3]); + +impl FooterMemo { + fn id(key: &str) -> egui::Id { + egui::Id::new((key, "footer_memo")) + } + + fn load(ui: &Ui, key: &str) -> FooterMemo { + ui.ctx() + .data(|d| d.get_temp(Self::id(key))) + .unwrap_or_default() + } + + /// Each form's height: `guess` full before anything was drawn, a + /// form not drawn `fold` (the card's checks) and `tighten` shorter + /// than the one before it. + fn heights(&self, guess: f32, fold: f32, tighten: f32) -> [f32; 3] { + let off = [0.0, -fold, -fold - tighten]; + let anchor = (0..3) + .find_map(|i| self.0[i].map(|h| h - off[i])) + .unwrap_or(guess); + std::array::from_fn(|i| self.0[i].unwrap_or(anchor + off[i])) + } + + /// Keep form `form`'s drawn height for the next frame (the footer's + /// content changing moves every form alike), the footer's rect for + /// the tests, and draw again at once when it changed. + fn measured(mut self, ui: &Ui, key: &str, form: usize, rect: egui::Rect) { + let h = rect.height(); + let changed = match self.0[form] { + Some(old) if (old - h).abs() <= 0.5 => false, + Some(old) => { + for v in self.0.iter_mut().flatten() { + *v += h - old; + } + true + } + None => { + self.0[form] = Some(h); + true + } + }; + ui.ctx().data_mut(|d| { + d.insert_temp(Self::id(key), self); + d.insert_temp(egui::Id::new(format!("{key}_footer_rect")), rect); + d.insert_temp(egui::Id::new(format!("{key}_footer_h")), h); + }); + if changed { + ui.ctx().request_repaint(); + } + } +} + +/// The footer's menus tightened, as the links are. +fn tighten(ui: &mut Ui) { + let s = ui.spacing_mut(); + s.item_spacing.y = GAP_MIN; + s.interact_size.y = 22.0; + s.button_padding.y = 2.0; +} + +/// Height of the fade over a clipped edge of the links. +const FADE: f32 = 28.0; + +/// A panel's links, `row` draws one: at most `max_h` high, scrolling +/// when they don't fit, then with a fade and a chevron at each clipped +/// edge, and the current page's link scrolled into view whenever the +/// page changes. What it drew is kept for the tests +/// (`{key}_links_view`, `{key}_links_scroll`). +fn links( + ui: &mut Ui, + key: &str, + fit: Fit, + max_h: f32, + current: Section, + mut row: impl FnMut(&mut Ui, Section) -> egui::Response, +) { + // Last frame's page, viewport height and scrolling: the current link + // is scrolled into view when they change while the links scroll. + let shown_id = egui::Id::new((key, "links_shown")); + let shown = ui + .ctx() + .data(|d| d.get_temp::<(Section, i32, bool)>(shown_id)); + let now = (current, max_h.round() as i32); + let reveal = fit.scroll && shown != Some((now.0, now.1, true)); + let out = egui::ScrollArea::vertical() + .id_salt(format!("{key}_links")) + .max_height(max_h) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.y = fit.gap; + for s in Section::ALL { + let resp = row(ui, s); + if reveal && s == current { + resp.scroll_to_me(Some(Align::Center)); + } + } + }); + let view = out.inner_rect; + let content = out.content_size.y; + let offset = out.state.offset.y; + let scrolls = content > view.height() + slack(ui); + ui.ctx().data_mut(|d| { + d.insert_temp(shown_id, (now.0, now.1, scrolls)); + d.insert_temp(egui::Id::new(format!("{key}_links_view")), view); + d.insert_temp(egui::Id::new(format!("{key}_links_scroll")), scrolls); + }); + if !scrolls { + return; + } + let p = pal(ui.ctx()); + let painter = ui.painter().with_clip_rect(view); + let above = offset > 0.5; + let below = offset + view.height() < content - 0.5; + for (clipped, down) in [(above, false), (below, true)] { + if !clipped { + continue; + } + let h = FADE.min(view.height() / 2.0); + let (band, edge_y) = if down { + ( + egui::Rect::from_min_max(egui::pos2(view.left(), view.bottom() - h), view.max), + view.bottom() - 8.0, + ) + } else { + ( + egui::Rect::from_min_max(view.min, egui::pos2(view.right(), view.top() + h)), + view.top() + 8.0, + ) + }; + let (top, bottom) = if down { + (egui::Color32::TRANSPARENT, p.sidebar) + } else { + (p.sidebar, egui::Color32::TRANSPARENT) + }; + let mut mesh = egui::Mesh::default(); + mesh.colored_vertex(band.left_top(), top); + mesh.colored_vertex(band.right_top(), top); + mesh.colored_vertex(band.left_bottom(), bottom); + mesh.colored_vertex(band.right_bottom(), bottom); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 3, 2); + painter.add(egui::Shape::mesh(mesh)); + // The sidebar's at the edge's right end, clear of the links' + // names. The rail's has no room beside its icons (there it read + // as a caret on one): centred on the edge, on a pill of its own + // that covers the faded icon under it. + let narrow = view.width() < 80.0; + let chevron = if narrow { + egui::Rect::from_center_size( + egui::pos2( + view.center().x, + if down { + view.bottom() - 7.0 + } else { + view.top() + 7.0 + }, + ), + vec2(26.0, 14.0), + ) + } else { + egui::Rect::from_center_size(egui::pos2(view.right() - 14.0, edge_y), vec2(24.0, 16.0)) + }; + let label = if down { + "More links below" + } else { + "More links above" + }; + let resp = ui + .interact(chevron, ui.id().with((key, down)), Sense::click()) + .on_hover_text(label); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + if narrow { + painter.rect( + chevron, + CornerRadius::same(7), + if resp.hovered() { p.hover } else { p.sidebar }, + Stroke::new(1.0, p.border), + egui::StrokeKind::Inside, + ); + } + painter.text( + chevron.center(), + egui::Align2::CENTER_CENTER, + if down { + icon::CARET_DOWN + } else { + icon::CARET_UP + }, + egui::FontId::proportional(if narrow { 11.0 } else { 14.0 }), + if resp.hovered() { p.text } else { p.muted }, + ); + if resp.clicked() { + // A page of links on, keeping one link of the last in view. + let step = (view.height() - fit.row - fit.gap).max(fit.row); + let mut state = out.state; + state.offset.y = (offset + if down { step } else { -step }) + .clamp(0.0, (content - view.height()).max(0.0)); + state.store(ui.ctx(), out.id); + ui.ctx().request_repaint(); + } + } +} + /// The sidebar's footer, bottom up: on the panel's floor when it fits /// under the links, else right under them, running off the bottom of the /// window, so it gives way and never draws over a link. @@ -1006,14 +1353,13 @@ fn footer_layout( } } -fn remember_footer(ui: &Ui, id: egui::Id, drawn: f32, used: f32) { - if (drawn - used).abs() > 0.5 { - ui.ctx().data_mut(|d| d.insert_temp(id, drawn)); - ui.ctx().request_repaint(); +/// The status card: whether the books are live, their newest event, and +/// the integrity checks. Folded (a short window), one line, the checks +/// in its tooltip. +fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool, folded: bool) { + if folded { + return folded_status(ui, b, since_update, live); } -} - -fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) { let p = pal(ui.ctx()); Frame::new() .fill(p.surface) @@ -1064,6 +1410,176 @@ fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) { }); } +/// The status card folded to its summary line ("● Live · event 559 · +/// ✓ verified", or the check that failed), the three checks on hover +/// and the card's copy menu on a right click. The line is the fullest +/// of [`summaries`] that fits the card, so it is never cut off: a failed +/// check's name outranks the event and the word "Live" (review, +/// 2026-10-22: cut to "⊘ Datab…" it hid what failed). +fn folded_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) { + let p = pal(ui.ctx()); + let checks = checks(b); + let failed: Vec<&str> = checks + .iter() + .filter(|c| !c.0) + .map(|c| c.1.as_str()) + .collect(); + let status = if live { "Live" } else { "Snapshot" }; + let event = format!("· event {}", b.seq()); + // The card's inner width: the panel's less the frame's margins. + let room = ui.available_width() - 2.0 * 10.0 - 2.0; + let gap = 5.0; + let width = |s: &Summary| { + let w = |text: &str, font: egui::FontId| { + ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), font, p.text).size().x) + }; + let mut x = 8.0 + gap + w(s.verdict(), theme::regular(12.0)); + if s.status { + x += gap + w(status, theme::semibold(12.0)); + } + if s.event { + x += gap + w(&event, theme::regular(12.0)); + } + x + }; + let all = summaries(&failed); + let line = all + .iter() + .find(|s| width(s) <= room) + .unwrap_or_else(|| all.last().expect("a summary")); + let color = if failed.is_empty() { + p.muted + } else { + p.negative + }; + let card = Frame::new() + .fill(p.surface) + .stroke(Stroke::new(1.0, p.border)) + .corner_radius(CornerRadius::same(8)) + .inner_margin(Margin::same(10)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = gap; + let (r, _) = ui.allocate_exact_size(vec2(8.0, 8.0), Sense::hover()); + let pulse = (since_update < 2.0) as u8 as f32; + let dot = if live { p.positive } else { p.faint }; + ui.painter().circle_filled(r.center(), 4.0 + pulse, dot); + if line.status { + ui.label(RichText::new(status).font(theme::semibold(12.0))); + } + if line.event { + ui.label( + RichText::new(&event) + .font(theme::regular(12.0)) + .color(p.muted), + ); + } + ui.label( + RichText::new(line.verdict()) + .font(theme::regular(12.0)) + .color(color), + ); + }); + }); + let resp = ui.interact( + card.response.rect, + ui.id().with("folded_status"), + Sense::click(), + ); + resp.on_hover_ui(|ui| { + if !(line.status && line.event) { + ui.label( + RichText::new(format!("{status} {event}")) + .font(theme::regular(11.5)) + .color(p.muted), + ); + } + for (ok, text, _) in &checks { + 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), + ); + } + }) + .context_menu(|ui| { + crate::widgets::copy_item(ui, "Copy journal head", &b.head); + crate::widgets::copy_item(ui, "Copy data folder", &b.data_dir.display().to_string()); + }); +} + +/// One way to say the folded card's line: whether it names the status +/// ("Live") and the event, and its verdict. +#[derive(Clone, Debug, PartialEq)] +struct Summary { + status: bool, + event: bool, + verdict: String, +} + +impl Summary { + /// The verdict as drawn: right after the dot it needs no "·". + fn verdict(&self) -> &str { + if self.status { + &self.verdict + } else { + self.verdict.trim_start_matches("· ") + } + } +} + +/// The folded card's lines, fullest first, given the checks that +/// `failed`. All passing, the event outranks the word "verified" (the +/// seal says it). A failure is named before anything else is kept: the +/// event goes first, then "Live" (the dot's colour says it), and only +/// then is the name shortened to a count. Whatever the line leaves out +/// its tooltip says. +fn summaries(failed: &[&str]) -> Vec { + let s = |status, event, verdict: String| Summary { + status, + event, + verdict, + }; + match failed { + [] => { + let full = format!("· {} verified", icon::SEAL_CHECK); + let seal = format!("· {}", icon::SEAL_CHECK); + vec![ + s(true, true, full.clone()), + s(true, true, seal.clone()), + s(true, false, full), + s(true, false, seal.clone()), + s(false, false, seal), + ] + } + names => { + let named = format!("· {} {}", icon::WARNING_CIRCLE, names.join(", ")); + let counted = match names.len() { + 1 => format!("· {} check failed", icon::WARNING_CIRCLE), + n => format!("· {} {n} checks failed", icon::WARNING_CIRCLE), + }; + let short = match names.len() { + 1 => format!("· {} failed", icon::WARNING_CIRCLE), + n => format!("· {} {n} failed", icon::WARNING_CIRCLE), + }; + vec![ + s(true, true, named.clone()), + s(true, false, named.clone()), + s(false, false, named), + s(true, false, counted.clone()), + s(false, false, counted), + s(false, false, short), + ] + } + } +} + /// The integrity checks the sidebar shows: (ok, label, explanation). fn checks(b: &Books) -> Vec<(bool, String, String)> { use ledger_store::DiskCheck; @@ -1184,6 +1700,110 @@ mod tests { move |k| map.get(k).cloned() } + /// The footer's heights in the owner's window (measured 2026-10-22). + const FOOTER: [f32; 3] = [219.0, 147.0, 112.0]; + + #[test] + fn a_window_that_fits_everything_changes_nothing() { + let f = fit(900.0, FOOTER, &SIDEBAR_ROWS, 0.5); + assert_eq!( + (f.folded, f.tight, f.row, f.gap, f.scroll), + (false, false, 32.0, 6.0, false) + ); + } + + #[test] + fn the_sidebar_gives_way_in_order() { + // Card folds first. + let f = fit(18.0 + 450.0 + 8.0 + 147.0, FOOTER, &SIDEBAR_ROWS, 0.5); + assert!(f.folded && !f.tight && f.row == 32.0 && f.gap == 6.0); + // Then the spacing goes, before the rows. + let f = fit(8.0 + 410.0 + 8.0 + 112.0, FOOTER, &SIDEBAR_ROWS, 0.5); + assert!(f.folded && f.tight && f.row == 32.0 && f.gap < 6.0 && f.gap >= GAP_MIN); + // Then the rows, down to their least. + let f = fit(8.0 + 330.0 + 8.0 + 112.0, FOOTER, &SIDEBAR_ROWS, 0.5); + assert!(f.tight && !f.scroll && f.gap == GAP_MIN && f.row < 32.0 && f.row >= 24.0); + // Then they scroll. + let f = fit(300.0, FOOTER, &SIDEBAR_ROWS, 0.5); + assert!(f.scroll && f.row == 24.0 && f.gap == GAP_MIN); + } + + #[test] + fn whatever_fit_is_chosen_the_links_fit_unless_they_scroll() { + for g in [&SIDEBAR_ROWS, &RAIL_ROWS] { + let foot = if g.foldable { + FOOTER + } else { + [150.0, 150.0, 120.0] + }; + let mut last = (0, f32::INFINITY); + for tenths in (1000..12000).rev() { + let avail = tenths as f32 / 10.0; + let f = fit(avail, foot, g, 0.5); + let head = if f.tight { HEAD_GAP_TIGHT } else { g.head_gap }; + let need = head + g.height(f.row, f.gap) + FOOTER_GAP + foot[f.form()]; + // Within the scroll area's rounding (fit). + assert!(f.scroll || need <= avail + 0.5, "{avail}: {f:?}"); + assert!(f.row >= g.row_min && f.row <= g.row && f.gap >= GAP_MIN); + // Shorter windows never step back. + let step = (f.form() + f.scroll as usize, f.row + f.gap); + assert!(step.0 >= last.0, "{avail}: {f:?}"); + assert!(step.0 > last.0 || step.1 <= last.1, "{avail}: {f:?}"); + last = step; + } + } + } + + #[test] + fn a_folded_card_names_a_failure_before_keeping_anything_else() { + let lines = summaries(&["Database differs"]); + // Every line says it failed; the name goes only after the event + // and the word "Live" have. + assert!( + lines + .iter() + .all(|l| l.verdict.contains(icon::WARNING_CIRCLE)) + ); + let named = lines + .iter() + .take_while(|l| l.verdict.contains("Database differs")) + .count(); + assert_eq!(named, 3); + assert!(!lines[named - 1].status && !lines[named - 1].event); + assert!(lines[named..].iter().all(|l| l.verdict.contains("failed"))); + // Passing, the line is never a failure's and keeps its seal. + let ok = summaries(&[]); + assert!( + ok.iter().all(|l| l.verdict.contains(icon::SEAL_CHECK) + && !l.verdict.contains(icon::WARNING_CIRCLE)) + ); + assert_eq!( + ok[0], + Summary { + status: true, + event: true, + verdict: format!("· {} verified", icon::SEAL_CHECK), + } + ); + assert!( + summaries(&["a", "b"]) + .last() + .unwrap() + .verdict + .contains("2 failed") + ); + } + + #[test] + fn an_unmeasured_footer_form_is_reckoned_from_a_measured_one() { + let m = FooterMemo([None, Some(150.0), None]); + assert_eq!(m.heights(172.0, 70.0, 36.0), [220.0, 150.0, 114.0]); + assert_eq!( + FooterMemo::default().heights(172.0, 70.0, 36.0), + [172.0, 102.0, 66.0] + ); + } + #[test] fn the_kept_scheme_and_filter_come_back() { let (s, f) = restored( diff --git a/crates/ledger-gui/src/menu.rs b/crates/ledger-gui/src/menu.rs index 59ed5da..b940a36 100644 --- a/crates/ledger-gui/src/menu.rs +++ b/crates/ledger-gui/src/menu.rs @@ -56,7 +56,7 @@ impl Menu { }; let mut button = egui::Button::new(text).frame(false); if compact { - button = button.min_size(vec2(32.0, 32.0)); + button = button.min_size(vec2(32.0, ui.spacing().interact_size.y + 4.0)); } let busy = self.job.is_some(); let year = books.today.year(); diff --git a/crates/ledger-gui/src/owner.rs b/crates/ledger-gui/src/owner.rs index 7b60f55..6e681ab 100644 --- a/crates/ledger-gui/src/owner.rs +++ b/crates/ledger-gui/src/owner.rs @@ -301,7 +301,7 @@ impl OwnerMenu { }; let mut button = egui::Button::new(text).frame(false); if compact { - button = button.min_size(vec2(32.0, 32.0)); + button = button.min_size(vec2(32.0, ui.spacing().interact_size.y + 4.0)); } let busy = self.job.is_some(); let mut start: Option<(String, Box Outcome + Send>)> = None; diff --git a/crates/ledger-gui/tests/edges.rs b/crates/ledger-gui/tests/edges.rs index ca8dd6b..e039066 100644 --- a/crates/ledger-gui/tests/edges.rs +++ b/crates/ledger-gui/tests/edges.rs @@ -287,7 +287,8 @@ fn sidebar_link(h: &Harness<'_, App>, name: &str) -> egui::Rect { } /// A short window (owner, 2026-10-21: the links went under the status -/// box): scrolled to its end, the last link stops above the box. +/// box): scrolled to its end, the last link stops above the box, folded +/// to its one line there (tests/sidenav.rs has the whole grid). #[test] fn sidebar_links_never_draw_under_the_status_box() { let f = fixture("sidebar-short"); @@ -301,7 +302,7 @@ fn sidebar_links_never_draw_under_the_status_box() { .scroll_to_me(); h.run_steps(4); let journal = sidebar_link(&h, "Journal"); - let status = h.get_by_label_contains("files verified").rect(); + let status = h.get_by_label_contains("event ").rect(); assert!( journal.bottom() <= status.top(), "Journal ends at {} under the status box at {}", @@ -351,10 +352,10 @@ fn a_very_short_window_keeps_one_link() { h.run_steps(4); let first = sidebar_link(&h, "Overview"); assert!( - first.height() >= 31.0 && first.bottom() <= h_px, + first.height() >= 23.0 && first.bottom() <= h_px, "{tag}: Overview at {first:?}" ); - if let Some(status) = h.query_by_label_contains("files verified") { + if let Some(status) = h.query_by_label_contains("event ") { assert!( first.bottom() <= status.rect().top(), "{tag}: Overview ends at {} under the status box at {}", diff --git a/crates/ledger-gui/tests/goldens/sidenav-1440x920.png b/crates/ledger-gui/tests/goldens/sidenav-1440x920.png new file mode 100644 index 0000000000000000000000000000000000000000..c8c2063b0d668c4ed9db92fb607cd380ca9f77ed GIT binary patch literal 43860 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�Nty#}m0P<6N0vQ*%C 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 A$p8QV literal 0 HcmV?d00001 diff --git a/crates/ledger-gui/tests/goldens/sidenav-1440x920.shapes b/crates/ledger-gui/tests/goldens/sidenav-1440x920.shapes new file mode 100644 index 0000000..dfa893b --- /dev/null +++ b/crates/ledger-gui/tests/goldens/sidenav-1440x920.shapes @@ -0,0 +1 @@ +05381e0e275497fa diff --git a/crates/ledger-gui/tests/sidenav.rs b/crates/ledger-gui/tests/sidenav.rs new file mode 100644 index 0000000..6af686c --- /dev/null +++ b/crates/ledger-gui/tests/sidenav.rs @@ -0,0 +1,758 @@ +//! The sidebar's links fit the window (owner, 2026-10-22: at 1479x519 +//! half the links hid under the status box with no sign that they +//! scroll). As the window gets shorter the sidebar gives way in three +//! steps, each only when the one before isn't enough: the status card +//! folds to its summary line, then the rows tighten, then the links +//! scroll, with a fade and a chevron at the clipped edge. +//! +//! ```text +//! GOAL: every link reachable and never under the footer, at any height +//! and scale, sidebar and rail; a window where everything fits +//! looks exactly as it did. +//! SOURCE: app.rs (Fit, fit, sidebar, rail, live_status). +//! ``` +//! +//! `SHOT_DIR` keeps pictures for eyes (`sidenav_screenshots`). +//! `SIDENAV_BLESS=1` rewrites the goldens of the 1440x920 sidebar +//! (`tests/goldens/`): only from a commit whose sidebar is the one meant. + +use chrono::NaiveDate; +use egui::{Rect, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_gui::books::Books; +use ledger_gui::nav::{Route, Section}; +use ledger_gui::owner::{Agent, Owner, RestorePlan}; +use ledger_gui::{App, Source, data}; +use ledger_store::{DiskCheck, Store}; +use rust_decimal::Decimal; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +/// An owner that never gets asked anything here: it only makes the +/// viewer show its Books menu, as the live one does. +struct Quiet; + +impl Owner for Quiet { + fn close_books(&self) -> Result { + Err("not here".into()) + } + fn new_books(&self, _: &str, _: &str) -> Result { + Err("not here".into()) + } + fn back_up_now(&self, _: &str) -> Result, String> { + Err("not here".into()) + } + fn plan_restore(&self, _: &str, _: Option, _: bool) -> Result { + Err("not here".into()) + } + fn restore(&self, _: &RestorePlan) -> Result, String> { + Err("not here".into()) + } + fn connect(&self, _: Agent, _: &str) -> Result, String> { + Err("not here".into()) + } + fn upgrade_format(&self, _: &str, _: &str) -> Result, String> { + Err("not here".into()) + } +} + +fn owner() { + static ONCE: OnceLock<()> = OnceLock::new(); + ONCE.get_or_init(|| ledger_gui::owner::set_owner(Arc::new(Quiet))); +} + +fn today() -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 10, 22).unwrap() +} + +/// What the sidebar's footer can show: the live viewer's (a Books menu, +/// Live, every check passing) and each way it differs. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Footer { + /// The owner's: Books menu, Live, every check passing. + Owners, + /// Not following a journal: "Snapshot". + Snapshot, + Behind, + Ahead, + /// A copy of the books: no server database. + Missing, + Differs, + Unreadable, + /// Stored files damaged. + Damaged, + /// Books outside a data root: no Books menu. + NoMenu, +} + +const FOOTERS: [Footer; 9] = [ + Footer::Owners, + Footer::Snapshot, + Footer::Behind, + Footer::Ahead, + Footer::Missing, + Footer::Differs, + Footer::Unreadable, + Footer::Damaged, + Footer::NoMenu, +]; + +/// Books with a footer like the live one's: a data root's (its Books +/// menu), about 560 events and 110 stored receipts. Removed when dropped. +struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, + plain: PathBuf, +} + +fn usd(c: i64) -> Money { + Money::new(Decimal::new(c, 2), Currency::USD) +} + +fn fill(book: &Path) { + let mut s = Store::create(book, Currency::USD).unwrap(); + let mut add = |code: &str, ty| s.add_account(code, code, ty, None, false).unwrap(); + let cash = add("1000", AccountType::Asset); + let capital = add("3000", AccountType::Equity); + let rent = add("6000", AccountType::Expense); + for i in 0..110i64 { + let (dr, cr) = if i == 0 { + (cash, capital) + } else { + (rent, cash) + }; + let e = s + .create_entry(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), "test") + .unwrap(); + s.add_line(e, dr, Side::Debit, usd(100 + i)).unwrap(); + s.add_line(e, cr, Side::Credit, usd(100 + i)).unwrap(); + s.attach_receipt( + Some(e), + "r.pdf", + "application/pdf", + format!("%PDF {i}").as_bytes(), + None, + ) + .unwrap(); + s.post_entry(e).unwrap(); + } +} + +impl Fixture { + fn new() -> Fixture { + owner(); + let dir = tempfile::Builder::new() + .prefix("wb-gui-sidenav-") + .tempdir() + .unwrap(); + std::fs::write(dir.path().join(ledger_store::root::MARKER_FILE), b"").unwrap(); + let root = dir.path().join("books").join("wanderland"); + std::fs::create_dir_all(&root).unwrap(); + fill(&root); + // The same books outside the root: no Books menu. + let plain = tempfile::Builder::new() + .prefix("wb-gui-sidenav-plain-") + .tempdir() + .unwrap() + .keep(); + let plain_book = plain.join("books-data"); + copy_dir(&root, &plain_book); + Fixture { + _dir: dir, + root, + plain: plain_book, + } + } + + fn books(&self, footer: Footer) -> Books { + let data_dir = if footer == Footer::NoMenu { + self.plain.clone() + } else { + self.root.clone() + }; + let mut b = data::load_once(&Source { + data_dir, + today: Some(today()), + }) + .unwrap(); + match footer { + Footer::Behind => { + b.integrity.disk = DiskCheck::Behind { + applied: 550, + journal: 559, + } + } + Footer::Ahead => { + b.integrity.disk = DiskCheck::Ahead { + applied: 561, + journal: 559, + } + } + Footer::Missing => b.integrity.disk = DiskCheck::Missing, + Footer::Differs => { + b.integrity.disk = DiskCheck::Differs("the entries table differs".into()) + } + Footer::Unreadable => { + b.integrity.disk = DiskCheck::Unreadable("database disk image is malformed".into()) + } + Footer::Damaged => { + b.integrity.file_failures = vec![(3, "missing".into()), (9, "hash differs".into())] + } + _ => {} + } + b + } + + /// The viewer on `route`, `w`x`h` points at `scale`. + fn harness( + &self, + footer: Footer, + route: Route, + w: f32, + h: f32, + scale: f32, + ) -> Harness<'static, App> { + let b = self.books(footer); + let mut h = Harness::builder() + .with_size(vec2(w, h)) + .with_pixels_per_point(scale) + .build_eframe(move |cc| { + let mut a = App::with_books(&cc.egui_ctx, b, true, route); + a.set_scheme(&cc.egui_ctx, "Everforest/Dark"); + if footer != Footer::Snapshot { + a.show_live_label(); + } + a + }); + h.run_steps(6); + h + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + if let Some(p) = self.plain.parent() { + let _ = std::fs::remove_dir_all(p); + } + } +} + +fn copy_dir(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for e in std::fs::read_dir(from).unwrap() { + let p = e.unwrap().path(); + let dest = to.join(p.file_name().unwrap()); + if p.is_dir() { + copy_dir(&p, &dest); + } else { + std::fs::copy(&p, &dest).unwrap(); + } + } +} + +/// Sidebar or rail: which one the window's width shows. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Pane { + Sidebar, + Rail, +} + +impl Pane { + /// Its width. + fn panel(self) -> f32 { + match self { + Pane::Sidebar => 224.0, + Pane::Rail => 60.0, + } + } + /// A window width (points) that shows it. + fn width(self) -> f32 { + match self { + Pane::Sidebar => 1440.0, + Pane::Rail => 900.0, + } + } + fn key(self) -> &'static str { + match self { + Pane::Sidebar => "sidebar", + Pane::Rail => "rail", + } + } +} + +/// The link of section `s` (the leftmost button of its name). +fn link(h: &Harness<'_, App>, s: Section) -> Rect { + h.get_all_by_role_and_label(egui::accesskit::Role::Button, s.title()) + .map(|n| n.rect()) + .min_by(|a, b| a.left().total_cmp(&b.left())) + .unwrap_or_else(|| panic!("no {} link", s.title())) +} + +/// What the sidebar says it drew: the links' viewport, the footer's +/// rect, and whether the links scroll. +fn drawn(h: &Harness<'_, App>, side: Pane) -> (Rect, Rect, bool) { + let k = side.key(); + h.ctx.data(|d| { + ( + d.get_temp::(egui::Id::new(format!("{k}_links_view"))) + .expect("the links' viewport"), + d.get_temp::(egui::Id::new(format!("{k}_footer_rect"))) + .expect("the footer's rect"), + d.get_temp::(egui::Id::new(format!("{k}_links_scroll"))) + .expect("whether the links scroll"), + ) + }) +} + +const EPS: f32 = 0.01; + +/// `r` shows top to bottom inside `view`. Links are laid on whole +/// pixels and the viewport needn't be, so at a fractional scale they may +/// differ by under a pixel (half a point at most here); across, a link may +/// overrun the viewport likewise. +fn whole_in(view: Rect, r: Rect) -> bool { + r.top() >= view.top() - 0.5 && r.bottom() <= view.bottom() + 0.5 +} + +/// No link shows over or under the footer: the part of each link inside +/// the links' viewport ends above the footer and above every footer +/// widget the tree names; without scrolling every link shows whole. +fn assert_clear(h: &Harness<'_, App>, side: Pane, tag: &str) { + let (view, footer, scrolls) = drawn(h, side); + assert!( + view.bottom() <= footer.top() + EPS, + "{tag}: the links' viewport {view:?} reaches the footer {footer:?}" + ); + // The footer's own widgets, found in the tree, not by the hook. + let mut tops = vec![footer.top()]; + for label in ["Export", "event "] { + for n in h.query_all_by_label_contains(label) { + if n.rect().left() < side.panel() && n.rect().top() > view.top() { + tops.push(n.rect().top()); + } + } + } + let footer_top = tops.into_iter().fold(f32::INFINITY, f32::min); + let mut whole = 0; + for s in Section::ALL { + let r = link(h, s); + let seen = r.intersect(view); + if seen.is_positive() { + assert!( + seen.bottom() <= footer_top + EPS, + "{tag}: {} shows to {} under the footer at {footer_top}", + s.title(), + seen.bottom() + ); + } + if whole_in(view, r) { + whole += 1; + } else { + assert!( + scrolls, + "{tag}: {} at {r:?} cut off with no scrolling in {view:?}", + s.title() + ); + } + } + assert!( + whole >= 1, + "{tag}: no link shows whole in {view:?} (footer {footer:?}): {:?}", + Section::ALL.map(|s| link(h, s)) + ); +} + +/// Every height from 300 to 920 pixels in steps of 20, at scales 1 to 2, +/// sidebar and rail, with each footer the live viewer can show. +fn grid(scale: f32) { + let f = Fixture::new(); + for footer in FOOTERS { + for side in [Pane::Sidebar, Pane::Rail] { + let mut h = f.harness(footer, Route::Projects, side.width(), 920.0, scale); + for px in (300..=920).step_by(20) { + let pt = px as f32 / scale; + h.set_size(vec2(side.width(), pt)); + h.run_steps(4); + let tag = format!("{footer:?} {side:?} {px}px @{scale}"); + assert_clear(&h, side, &tag); + // The owner's complaint: in a window as tall as theirs + // (519) or taller, no link is out of sight, sidebar or rail. + if scale == 1.0 && px >= 519 { + assert!(!drawn(&h, side).2, "{tag}: the links scroll"); + } + } + } + } +} + +#[test] +fn no_link_is_ever_under_the_footer_at_scale_1() { + grid(1.0); +} + +#[test] +fn no_link_is_ever_under_the_footer_at_scale_1_25() { + grid(1.25); +} + +#[test] +fn no_link_is_ever_under_the_footer_at_scale_1_5() { + grid(1.5); +} + +#[test] +fn no_link_is_ever_under_the_footer_at_scale_2() { + grid(2.0); +} + +/// The owner's window: every link shows whole, nothing scrolls, and the +/// status card is its summary line, the checks in its tooltip. +#[test] +fn at_1479x519_every_link_shows_unscrolled() { + let f = Fixture::new(); + for footer in FOOTERS { + let mut h = f.harness(footer, Route::Projects, 1479.0, 519.0, 1.0); + let (view, _, scrolls) = drawn(&h, Pane::Sidebar); + assert!(!scrolls, "{footer:?}: the links scroll"); + for s in Section::ALL { + let r = link(&h, s); + assert!( + whole_in(view, r) && r.bottom() <= 519.0, + "{footer:?}: {} at {r:?} isn't whole in {view:?}", + s.title() + ); + assert!(r.height() >= 24.0, "{footer:?}: {} too tight", s.title()); + } + assert_clear(&h, Pane::Sidebar, &format!("{footer:?}")); + // Nothing moves when the last link is asked into view. + let first = link(&h, Section::Overview); + h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal") + .min_by(|a, b| a.rect().left().total_cmp(&b.rect().left())) + .unwrap() + .scroll_to_me(); + h.run_steps(4); + assert_eq!( + link(&h, Section::Overview), + first, + "{footer:?}: it scrolled" + ); + } +} + +/// Folded, the card is one line that fits the card whole (never cut to +/// "Datab…"), naming the check that failed; hovered, it shows the three +/// checks. +#[test] +fn the_folded_card_shows_its_checks_on_hover() { + let f = Fixture::new(); + for (footer, verdict, checks) in [ + ( + Footer::Owners, + "verified", + [ + "Journal chain verified", + "Database matches journal", + "110 files verified", + ], + ), + ( + Footer::Snapshot, + // Its line is "Snapshot · event 555 · (seal)": no room for + // "verified", which the seal says. + "Snapshot", + [ + "Journal chain verified", + "Database matches journal", + "110 files verified", + ], + ), + ( + Footer::Differs, + "Database differs", + [ + "Journal chain verified", + "Database differs", + "110 files verified", + ], + ), + ( + Footer::Unreadable, + "Database unreadable", + [ + "Journal chain verified", + "Database unreadable", + "110 files verified", + ], + ), + ( + Footer::Damaged, + "2 files damaged", + [ + "Journal chain verified", + "Database matches journal", + "2 files damaged", + ], + ), + ] { + for scale in [1.0, 1.25] { + let tag = format!("{footer:?} @{scale}"); + let mut h = f.harness( + footer, + Route::Projects, + 1479.0 / scale, + 519.0 / scale, + scale, + ); + assert!( + h.query_by_label_contains("files verified").is_none() + && h.query_by_label_contains("Journal chain verified") + .is_none(), + "{tag}: the card is folded" + ); + let (_, footer_rect, _) = drawn(&h, Pane::Sidebar); + let line = h.get_by_label_contains(verdict); + let card = line.rect(); + // Every piece of the line lies inside the card's margins: a + // piece cut off or running out would reach past them. + for piece in ["Live", "Snapshot", "event 5", verdict] { + for n in h.query_all_by_label_contains(piece) { + let r = n.rect(); + if r.left() < Pane::Sidebar.panel() && r.top() >= footer_rect.top() { + assert!( + r.right() <= footer_rect.right() - 10.0 + 0.5, + "{tag}: {piece:?} at {r:?} runs out of the card {footer_rect:?}" + ); + } + } + } + h.hover_at(card.center()); + h.run_steps(30); + for c in checks { + assert!( + h.query_all_by_label_contains(c).count() >= 1, + "{tag}: hovered, the card shows {c}" + ); + } + } + } +} + +/// The owner's height in the rail too: every link shows, unscrolled. +#[test] +fn the_rail_at_519_shows_every_link_unscrolled() { + let f = Fixture::new(); + for footer in FOOTERS { + for route in [Route::Projects, Route::Overview, Route::Journal] { + let h = f.harness(footer, route.clone(), 900.0, 519.0, 1.0); + let tag = format!("{footer:?} {route:?}"); + let (view, _, scrolls) = drawn(&h, Pane::Rail); + assert!(!scrolls, "{tag}: the rail's links scroll"); + for s in Section::ALL { + let r = link(&h, s); + assert!( + whole_in(view, r) && r.bottom() <= 519.0, + "{tag}: {} at {r:?} isn't whole in {view:?}", + s.title() + ); + } + assert_clear(&h, Pane::Rail, &tag); + } + } +} + +/// Where everything fits, the card shows its checks, unfolded. +#[test] +fn a_tall_window_keeps_the_whole_card() { + let f = Fixture::new(); + let h = f.harness(Footer::Owners, Route::Projects, 1440.0, 920.0, 1.0); + for c in [ + "Journal chain verified", + "Database matches journal", + "110 files verified", + ] { + assert!(h.query_by_label_contains(c).is_some(), "{c}"); + } + let (_, _, scrolls) = drawn(&h, Pane::Sidebar); + assert!(!scrolls); +} + +/// Too short even for tight rows: the links scroll, the current page's +/// link is scrolled into view, and a chevron says more links are there. +#[test] +fn scrolling_links_show_the_current_one_and_a_cue() { + let f = Fixture::new(); + for side in [Pane::Sidebar, Pane::Rail] { + let h = f.harness(Footer::Owners, Route::Journal, side.width(), 300.0, 1.0); + let (view, _, scrolls) = drawn(&h, side); + assert!(scrolls, "{side:?}: 300 points can't hold every link"); + let journal = link(&h, Section::Journal); + assert!( + whole_in(view, journal), + "{side:?}: Journal at {journal:?} not in {view:?}" + ); + assert!( + h.query_by_label("More links above").is_some(), + "{side:?}: the cue above" + ); + let mut h = f.harness(Footer::Owners, Route::Overview, side.width(), 300.0, 1.0); + assert!( + h.query_by_label("More links below").is_some(), + "{side:?}: the cue below" + ); + assert!(h.query_by_label("More links above").is_none(), "{side:?}"); + // The chevron scrolls the links. + let before = link(&h, Section::Overview); + h.get_by_label("More links below").click(); + h.run_steps(6); + assert!( + link(&h, Section::Overview).top() < before.top(), + "{side:?}: the chevron scrolled" + ); + } +} + +/// Going to a page scrolls its link into view (the palette, a crumb). +#[test] +fn going_somewhere_scrolls_its_link_into_view() { + let f = Fixture::new(); + let mut h = f.harness(Footer::Owners, Route::Overview, 1440.0, 300.0, 1.0); + for (page, s) in [ + ("journal", Section::Journal), + ("overview", Section::Overview), + ("documents", Section::Documents), + ] { + h.state_mut() + .show(ledger_store::ops::ViewerTarget::Page { page: page.into() }); + h.run_steps(6); + let (view, _, _) = drawn(&h, Pane::Sidebar); + let r = link(&h, s); + assert!(whole_in(view, r), "{} at {r:?} in {view:?}", s.title()); + } +} + +/// 1440x920, where everything always fit: pixel for pixel the sidebar +/// main drew (2d7b12a), and the same shapes. +#[test] +fn a_tall_sidebar_is_unchanged() { + let f = Fixture::new(); + let mut h = f.harness(Footer::Owners, Route::Overview, 1440.0, 920.0, 1.0); + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/goldens"); + let shapes = sidebar_shapes(&h); + let bless = std::env::var("SIDENAV_BLESS").is_ok(); + let shapes_file = dir.join("sidenav-1440x920.shapes"); + if bless { + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(&shapes_file, format!("{shapes:016x}\n")).unwrap(); + } else { + let want = std::fs::read_to_string(&shapes_file).unwrap(); + assert_eq!( + format!("{shapes:016x}"), + want.trim(), + "the sidebar's shapes at 1440x920 changed" + ); + } + // Pixels too, where this machine can render (CI has no GPU). + let png = dir.join("sidenav-1440x920.png"); + match h.render() { + Ok(img) => { + let side = image::imageops::crop_imm(&img, 0, 0, 224, img.height()).to_image(); + if bless { + side.save(&png).unwrap(); + } else { + let want = image::open(&png).unwrap().to_rgba8(); + assert_eq!(side.dimensions(), want.dimensions()); + let diff = side + .pixels() + .zip(want.pixels()) + .filter(|(a, b)| a != b) + .count(); + assert_eq!(diff, 0, "{diff} sidebar pixels differ from main's"); + } + } + Err(e) => eprintln!("no renderer ({e}): pixels not compared, shapes were"), + } +} + +/// A hash of every shape painted in the sidebar's column (FNV-1a over +/// their debug form): the same shapes paint the same pixels. +fn sidebar_shapes(h: &Harness<'_, App>) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for s in &h.output().shapes { + if s.clip_rect.max.x <= 224.5 { + for b in format!("{:?}", s.shape).bytes() { + hash ^= b as u64; + hash = hash.wrapping_mul(0x100_0000_01b3); + } + } + } + hash +} + +/// Pictures for eyes: set `SHOT_DIR`. +#[test] +fn sidenav_screenshots() { + let Ok(out) = std::env::var("SHOT_DIR") else { + return; + }; + let out = PathBuf::from(out); + std::fs::create_dir_all(&out).unwrap(); + let f = Fixture::new(); + let shots: [(&str, Route, f32, f32, f32); 11] = [ + ("sidebar-1479x519", Route::Projects, 1479.0, 519.0, 1.0), + ( + "sidebar-1479x519-overview", + Route::Overview, + 1479.0, + 519.0, + 1.0, + ), + ( + "sidebar-1479x519-journal", + Route::Journal, + 1479.0, + 519.0, + 1.0, + ), + ("sidebar-1479x420", Route::Projects, 1479.0, 420.0, 1.0), + ("sidebar-1479x340", Route::Projects, 1479.0, 340.0, 1.0), + ( + "sidebar-1479x340-journal", + Route::Journal, + 1479.0, + 340.0, + 1.0, + ), + ("sidebar-1440x920", Route::Overview, 1440.0, 920.0, 1.0), + // 986x346 points: narrow enough for the rail. + ("rail-1479x519-scale1.5", Route::Projects, 986.0, 346.0, 1.5), + ("rail-900x519", Route::Projects, 900.0, 519.0, 1.0), + ("rail-900x340", Route::Projects, 900.0, 340.0, 1.0), + ("rail-900x920", Route::Projects, 900.0, 920.0, 1.0), + ]; + for (name, route, w, hgt, scale) in shots { + let mut h = f.harness(Footer::Owners, route, w, hgt, scale); + if let Ok(img) = h.render() { + img.save(out.join(format!("{name}.png"))).unwrap(); + } + } + // The folded card in each footer, at the owner's size. + for footer in FOOTERS { + let mut h = f.harness(footer, Route::Projects, 1479.0, 519.0, 1.0); + if let Ok(img) = h.render() { + img.save(out.join(format!("folded-{footer:?}.png"))) + .unwrap(); + } + } + // The folded card's tooltip. + let mut h = f.harness(Footer::Owners, Route::Projects, 1479.0, 519.0, 1.0); + if let Some(n) = h.query_by_label_contains("event 5") { + let c = n.rect().center(); + h.hover_at(c); + h.run_steps(30); + if let Ok(img) = h.render() { + img.save(out.join("sidebar-1479x519-hover.png")).unwrap(); + } + } +}