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 0000000..c8c2063 Binary files /dev/null and b/crates/ledger-gui/tests/goldens/sidenav-1440x920.png differ 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(); + } + } +}