feat: 2D multi-level grid headers with repeat suppression

Column headers now render one row per column category instead of
joining with '/'. Row headers render one sub-column per row category.
Repeat suppression hides labels when the prefix is unchanged from
the previous row/column.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-24 09:32:01 -07:00
parent c8b88c63b3
commit c42553fa97
3 changed files with 4701 additions and 33 deletions

View File

@ -35,34 +35,53 @@ impl<'a> GridWidget<'a> {
let col_offset = view.col_offset;
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
let n_col_levels = layout.col_cats.len().max(1);
let n_row_levels = layout.row_cats.len().max(1);
// Sub-column widths for row header area
let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16;
let sub_widths: Vec<u16> = (0..n_row_levels).map(|d| {
if d < n_row_levels - 1 { sub_col_w }
else { ROW_HEADER_WIDTH.saturating_sub(sub_col_w * (n_row_levels as u16 - 1)) }
}).collect();
let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize;
let visible_col_range = col_offset..(col_offset + available_cols.max(1)).min(layout.col_count());
let available_rows = area.height.saturating_sub(2) as usize;
let header_rows = n_col_levels as u16 + 1; // +1 for separator
let available_rows = area.height.saturating_sub(header_rows) as usize;
let visible_row_range = row_offset..(row_offset + available_rows.max(1)).min(layout.row_count());
let mut y = area.y;
// Column headers
// Column headers — one row per level, with repeat suppression
let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
buf.set_string(area.x, y,
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
Style::default());
let mut x = area.x + ROW_HEADER_WIDTH;
for ci in visible_col_range.clone() {
let label = layout.col_label(ci);
let styled = if ci == sel_col {
header_style.add_modifier(Modifier::UNDERLINED)
} else {
header_style
};
buf.set_string(x, y,
format!("{:>width$}", truncate(&label, COL_WIDTH as usize), width = COL_WIDTH as usize),
styled);
x += COL_WIDTH;
if x >= area.x + area.width { break; }
for d in 0..n_col_levels {
buf.set_string(area.x, y,
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
Style::default());
let mut x = area.x + ROW_HEADER_WIDTH;
for ci in visible_col_range.clone() {
let label = if layout.col_cats.is_empty() {
layout.col_label(ci)
} else {
let show = ci == 0
|| layout.col_items[ci][..=d] != layout.col_items[ci - 1][..=d];
if show { layout.col_items[ci][d].clone() } else { String::new() }
};
let styled = if ci == sel_col {
header_style.add_modifier(Modifier::UNDERLINED)
} else {
header_style
};
buf.set_string(x, y,
format!("{:>width$}", truncate(&label, COL_WIDTH as usize), width = COL_WIDTH as usize),
styled);
x += COL_WIDTH;
if x >= area.x + area.width { break; }
}
y += 1;
}
y += 1;
// Separator
buf.set_string(area.x, y,
@ -74,15 +93,28 @@ impl<'a> GridWidget<'a> {
for ri in visible_row_range.clone() {
if y >= area.y + area.height { break; }
let row_label = layout.row_label(ri);
let row_style = if ri == sel_row {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
buf.set_string(area.x, y,
format!("{:<width$}", truncate(&row_label, ROW_HEADER_WIDTH as usize - 1), width = ROW_HEADER_WIDTH as usize),
row_style);
// Multi-level row header — one sub-column per row category
let mut hx = area.x;
for d in 0..n_row_levels {
let sw = sub_widths[d] as usize;
let label = if layout.row_cats.is_empty() {
layout.row_label(ri)
} else {
let show = ri == 0
|| layout.row_items[ri][..=d] != layout.row_items[ri - 1][..=d];
if show { layout.row_items[ri][d].clone() } else { String::new() }
};
buf.set_string(hx, y,
format!("{:<width$}", truncate(&label, sw), width = sw),
row_style);
hx += sub_widths[d];
}
let mut x = area.x + ROW_HEADER_WIDTH;
for ci in visible_col_range.clone() {
@ -446,11 +478,14 @@ mod tests {
m.active_view_mut().set_axis("Recipient", crate::view::Axis::Row);
let text = buf_text(&render(&m, 80, 24));
// Cross-product rows: Food/Alice, Food/Bob, Clothing/Alice, Clothing/Bob
assert!(text.contains("Food/Alice"), "expected 'Food/Alice' in:\n{text}");
assert!(text.contains("Food/Bob"), "expected 'Food/Bob' in:\n{text}");
assert!(text.contains("Clothing/Alice"), "expected 'Clothing/Alice' in:\n{text}");
assert!(text.contains("Clothing/Bob"), "expected 'Clothing/Bob' in:\n{text}");
// Multi-level row headers: category values shown separately, not joined with /
assert!(!text.contains("Food/Alice"), "slash-joined labels should be gone:\n{text}");
assert!(!text.contains("Clothing/Bob"), "slash-joined labels should be gone:\n{text}");
// Each item name appears on its own
assert!(text.contains("Food"), "expected 'Food' in:\n{text}");
assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}");
assert!(text.contains("Alice"), "expected 'Alice' in:\n{text}");
assert!(text.contains("Bob"), "expected 'Bob' in:\n{text}");
}
#[test]
@ -484,7 +519,11 @@ mod tests {
m.active_view_mut().set_axis("Year", crate::view::Axis::Column);
let text = buf_text(&render(&m, 80, 24));
assert!(text.contains("Jan/2024"), "expected 'Jan/2024' in:\n{text}");
assert!(text.contains("Jan/2025"), "expected 'Jan/2025' in:\n{text}");
// Multi-level column headers: category values shown separately, not joined with /
assert!(!text.contains("Jan/2024"), "slash-joined headers should be gone:\n{text}");
assert!(!text.contains("Jan/2025"), "slash-joined headers should be gone:\n{text}");
assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}");
assert!(text.contains("2024"), "expected '2024' in:\n{text}");
assert!(text.contains("2025"), "expected '2025' in:\n{text}");
}
}