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:
4562
foo.improv
Normal file
4562
foo.improv
Normal file
File diff suppressed because it is too large
Load Diff
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user