refactor: use data_col_to_visual via group_for helpers, add column group toggle

Add row_group_for/col_group_for to GridLayout, replacing inline
backward-search logic. Refactor grid renderer to use col_group_for
instead of pre-filtering col_items. Add gz keybinding for column
group collapse toggle, symmetric with z for rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-04-02 10:21:41 -07:00
parent 5136aadd86
commit edd6431444
4 changed files with 174 additions and 41 deletions

View File

@ -395,6 +395,10 @@ impl App {
view.selected = (0, view.selected.1);
view.row_offset = 0;
}
// gz = toggle column group under cursor
('g', KeyCode::Char('z')) => {
self.toggle_col_group_under_cursor();
}
// yy = yank current cell
('y', KeyCode::Char('y')) => {
if let Some(key) = self.selected_cell_key() {
@ -1222,28 +1226,35 @@ impl App {
fn toggle_group_under_cursor(&mut self) {
let layout = GridLayout::new(&self.model, self.model.active_view());
let sel_row = self.model.active_view().selected.0;
let Some(vi) = layout.data_row_to_visual(sel_row) else {
let Some((cat, group)) = layout.row_group_for(sel_row) else {
return;
};
let group = layout.row_items[..vi].iter().rev().find_map(|e| {
if let AxisEntry::GroupHeader {
cat_name,
group_name,
} = e
{
Some((cat_name.clone(), group_name.clone()))
} else {
None
}
});
if let Some((cat, group)) = group {
let cmd = Command::ToggleGroup {
category: cat,
group,
};
command::dispatch(&mut self.model, &cmd);
self.dirty = true;
let cmd = Command::ToggleGroup {
category: cat,
group,
};
command::dispatch(&mut self.model, &cmd);
self.dirty = true;
}
fn toggle_col_group_under_cursor(&mut self) {
let layout = GridLayout::new(&self.model, self.model.active_view());
let sel_col = self.model.active_view().selected.1;
let Some((cat, group)) = layout.col_group_for(sel_col) else {
return;
};
let cmd = Command::ToggleGroup {
category: cat,
group,
};
command::dispatch(&mut self.model, &cmd);
// Clamp selection if col_count shrank
let new_count = GridLayout::new(&self.model, self.model.active_view()).col_count();
let view = self.model.active_view_mut();
if view.selected.1 >= new_count && new_count > 0 {
view.selected.1 = new_count - 1;
}
self.dirty = true;
}
fn hide_selected_row_item(&mut self) {

View File

@ -79,19 +79,10 @@ impl<'a> GridWidget<'a> {
})
.collect();
// Map each data-col index to its group name (None if ungrouped)
let col_groups: Vec<Option<String>> = {
let mut groups = Vec::new();
let mut current: Option<String> = None;
for entry in &layout.col_items {
match entry {
AxisEntry::GroupHeader { group_name, .. } => current = Some(group_name.clone()),
AxisEntry::DataItem(_) => groups.push(current.clone()),
}
}
groups
};
let has_col_groups = col_groups.iter().any(|g| g.is_some());
let has_col_groups = layout
.col_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }));
let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize;
let visible_col_range =
@ -117,24 +108,35 @@ impl<'a> GridWidget<'a> {
Style::default(),
);
let mut x = area.x + ROW_HEADER_WIDTH;
let mut prev_group: Option<&str> = None;
let mut prev_group: Option<String> = None;
for ci in visible_col_range.clone() {
if x >= area.x + area.width {
break;
}
let group = col_groups[ci].as_deref();
let label = if group != prev_group {
group.unwrap_or("")
let col_group = layout.col_group_for(ci);
let group_name = col_group.as_ref().map(|(_, g)| g.clone());
let label = if group_name != prev_group {
match &col_group {
Some((cat, g)) => {
let indicator = if view.is_group_collapsed(cat, g) {
GROUP_COLLAPSED
} else {
GROUP_EXPANDED
};
format!("{indicator} {g}")
}
None => String::new(),
}
} else {
""
String::new()
};
prev_group = group;
prev_group = group_name;
buf.set_string(
x,
y,
format!(
"{:<width$}",
truncate(label, COL_WIDTH as usize),
truncate(&label, COL_WIDTH as usize),
width = COL_WIDTH as usize
),
group_style,