diff --git a/src/ui/grid.rs b/src/ui/grid.rs index a61e263..d53c49b 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -217,8 +217,7 @@ impl<'a> GridWidget<'a> { let label = if layout.col_cats.is_empty() { layout.col_label(ci) } else { - let show = ci == 0 || data_col_items[ci][..=d] != data_col_items[ci - 1][..=d]; - if show { + if show_sublabel(ci, col_offset, d, &data_col_items) { data_col_items[ci][d].clone() } else { String::new() @@ -340,9 +339,7 @@ impl<'a> GridWidget<'a> { let label = if layout.row_cats.is_empty() { layout.row_label(ri) } else { - let show = - ri == 0 || data_row_items[ri][..=d] != data_row_items[ri - 1][..=d]; - if show { + if show_sublabel(ri, row_offset, d, &data_row_items) { data_row_items[ri][d].clone() } else { String::new() @@ -649,6 +646,15 @@ pub fn compute_visible_cols( count.max(1) } +/// Decide whether the multi-level header sub-label at depth `d` for the data +/// entry `idx` should be rendered, or blanked because it repeats the previous +/// visible entry's prefix. `first_rendered` is the index of the first entry +/// actually on screen (the row/col scroll offset): that entry always shows its +/// full labels so a scrolled viewport keeps its group context. +fn show_sublabel(idx: usize, first_rendered: usize, d: usize, items: &[&Vec]) -> bool { + idx == first_rendered || items[idx][..=d] != items[idx - 1][..=d] +} + // Re-export shared formatting functions pub use crate::format::{format_f64, parse_number_format}; @@ -678,7 +684,7 @@ fn truncate(s: &str, max_width: usize) -> String { mod tests { use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; - use super::GridWidget; + use super::{show_sublabel, GridWidget}; use crate::formula::parse_formula; use crate::model::cell::{CellKey, CellValue}; use crate::ui::app::AppMode; @@ -1060,4 +1066,49 @@ mod tests { assert!(text.contains("2024"), "expected '2024' in:\n{text}"); assert!(text.contains("2025"), "expected '2025' in:\n{text}"); } + + // ── Multi-level header repeat suppression ───────────────────────────────── + + /// Items: [A,x], [A,y], [B,z] — multi-level header tuples. + fn sublabel_items() -> Vec> { + [["A", "x"], ["A", "y"], ["B", "z"]] + .iter() + .map(|t| t.iter().map(|s| s.to_string()).collect()) + .collect() + } + + /// Bug (improvise-jjx): suppression compared against the entry at + /// `idx - 1` even when that entry is scrolled off-screen. The first + /// RENDERED entry must always show its full labels, otherwise scrolling + /// blanks the group label at the top of the viewport. + #[test] + fn first_rendered_entry_always_shows_sublabel_when_scrolled() { + let items = sublabel_items(); + let refs: Vec<&Vec> = items.iter().collect(); + // Scrolled so index 1 ([A,y]) is the first visible entry. Its level-0 + // prefix matches off-screen index 0 ([A,x]) — it must still show "A". + assert!( + show_sublabel(1, 1, 0, &refs), + "first rendered entry must show its label even when its prefix matches the off-screen entry above" + ); + } + + #[test] + fn repeated_prefix_below_first_rendered_entry_is_suppressed() { + let items = sublabel_items(); + let refs: Vec<&Vec> = items.iter().collect(); + // Unscrolled: index 1 repeats index 0's level-0 prefix → suppressed. + assert!(!show_sublabel(1, 0, 0, &refs)); + // Leaf level differs → shown. + assert!(show_sublabel(1, 0, 1, &refs)); + } + + #[test] + fn changed_prefix_is_always_shown() { + let items = sublabel_items(); + let refs: Vec<&Vec> = items.iter().collect(); + assert!(show_sublabel(0, 0, 0, &refs)); + assert!(show_sublabel(2, 0, 0, &refs)); // B != A + assert!(show_sublabel(2, 1, 0, &refs)); // scrolled, still differs + } }