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

@ -219,9 +219,11 @@ impl App {
(KeyCode::Char('d'), KeyModifiers::CONTROL) => { self.scroll_rows(5); }
(KeyCode::Char('u'), KeyModifiers::CONTROL) => { self.scroll_rows(-5); }
// Enter = advance (down, wrapping to top of next column)
(KeyCode::Enter, _) => { self.enter_advance(); }
// ── Editing ────────────────────────────────────────────────────
(KeyCode::Enter, _)
| (KeyCode::Char('i'), KeyModifiers::NONE)
(KeyCode::Char('i'), KeyModifiers::NONE)
| (KeyCode::Char('a'), KeyModifiers::NONE) => {
let current = self.selected_cell_key()
.and_then(|k| self.model.get_cell(&k).cloned())
@ -1107,10 +1109,33 @@ impl App {
self.mode = AppMode::ImportWizard;
}
/// Advance selection down one row; when at the last row, wrap to row 0 of
/// the next column (typewriter-style). Does nothing if the grid is empty.
pub fn enter_advance(&mut self) {
let (row_max, col_max) = {
let layout = GridLayout::new(&self.model, self.model.active_view());
(layout.row_count().saturating_sub(1), layout.col_count().saturating_sub(1))
};
let view = self.model.active_view_mut();
let (r, c) = view.selected;
let (nr, nc) = if r < row_max {
(r + 1, c)
} else if c < col_max {
(0, c + 1)
} else {
(r, c) // already at bottom-right; stay
};
view.selected = (nr, nc);
if nr < view.row_offset { view.row_offset = nr; }
if nr >= view.row_offset + 20 { view.row_offset = nr.saturating_sub(19); }
if nc < view.col_offset { view.col_offset = nc; }
if nc >= view.col_offset + 8 { view.col_offset = nc.saturating_sub(7); }
}
/// Hint text for the status bar (context-sensitive)
pub fn hint_text(&self) -> &'static str {
match &self.mode {
AppMode::Normal => "hjkl:nav i:edit x:clear /:search F/C/V:panels T:tiles [:]:page ::cmd",
AppMode::Normal => "hjkl:nav Enter:advance i:edit x:clear /:search F/C/V:panels T:tiles [:]:page ::cmd",
AppMode::Editing { .. } => "Enter:commit Esc:cancel",
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
@ -1126,3 +1151,45 @@ impl App {
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Model;
fn two_col_model() -> App {
let mut m = Model::new("T");
m.add_category("Row").unwrap(); // → Row axis
m.add_category("Col").unwrap(); // → Column axis
m.category_mut("Row").unwrap().add_item("A");
m.category_mut("Row").unwrap().add_item("B");
m.category_mut("Row").unwrap().add_item("C");
m.category_mut("Col").unwrap().add_item("X");
m.category_mut("Col").unwrap().add_item("Y");
App::new(m, None)
}
#[test]
fn enter_advance_moves_down_within_column() {
let mut app = two_col_model();
app.model.active_view_mut().selected = (0, 0);
app.enter_advance();
assert_eq!(app.model.active_view().selected, (1, 0));
}
#[test]
fn enter_advance_wraps_to_top_of_next_column() {
let mut app = two_col_model();
// row_max = 2 (A,B,C), col 0 → should wrap to (0, 1)
app.model.active_view_mut().selected = (2, 0);
app.enter_advance();
assert_eq!(app.model.active_view().selected, (0, 1));
}
#[test]
fn enter_advance_stays_at_bottom_right() {
let mut app = two_col_model();
app.model.active_view_mut().selected = (2, 1);
app.enter_advance();
assert_eq!(app.model.active_view().selected, (2, 1));
}
}