fix: multi-category axis navigation and cell key resolution

move_selection, jump_to_last_row/col, and selected_cell_key all used
items.get(sel_row) on the first axis category, which returned None for
any cursor position beyond that category's item count. They now compute
the full Cartesian product (via cross_product_strs) and index into it,
so navigation and cell edits work correctly with multiple categories on
the same axis.

Also adds viewport-following scroll in move_selection/jump helpers so
the cursor stays visible when navigating past the visible window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-21 23:51:30 -07:00
parent 2b5e3b3576
commit 8063a484e1

View File

@ -908,62 +908,50 @@ impl App {
// ── Motion helpers ─────────────────────────────────────────────────────── // ── Motion helpers ───────────────────────────────────────────────────────
fn move_selection(&mut self, dr: i32, dc: i32) { fn move_selection(&mut self, dr: i32, dc: i32) {
// Compute max row/col from actual item counts so we never go out of bounds. // Use cross-product counts so multi-category axes navigate correctly.
let row_max = { let (row_max, col_max) = {
let row_cats: Vec<String> = self.model.active_view() let view = match self.model.active_view() { Some(v) => v, None => return };
.map(|v| v.categories_on(crate::view::Axis::Row).into_iter().map(String::from).collect()) let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
.unwrap_or_default(); let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
row_cats.first() let rm = cross_product_strs(&row_cats, &self.model, view).len().saturating_sub(1);
.and_then(|c| self.model.category(c)) let cm = cross_product_strs(&col_cats, &self.model, view).len().saturating_sub(1);
.map(|c| c.items.len().saturating_sub(1)) (rm, cm)
.unwrap_or(0)
};
let col_max = {
let col_cats: Vec<String> = self.model.active_view()
.map(|v| v.categories_on(crate::view::Axis::Column).into_iter().map(String::from).collect())
.unwrap_or_default();
col_cats.first()
.and_then(|c| self.model.category(c))
.map(|c| c.items.len().saturating_sub(1))
.unwrap_or(0)
}; };
if let Some(view) = self.model.active_view_mut() { if let Some(view) = self.model.active_view_mut() {
let (r, c) = view.selected; let (r, c) = view.selected;
view.selected = ( let nr = (r as i32 + dr).clamp(0, row_max as i32) as usize;
(r as i32 + dr).clamp(0, row_max as i32) as usize, let nc = (c as i32 + dc).clamp(0, col_max as i32) as usize;
(c as i32 + dc).clamp(0, col_max as i32) as usize, view.selected = (nr, nc);
); // Keep cursor in visible area (approximate viewport: 20 rows, 8 cols)
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); }
} }
} }
fn jump_to_last_row(&mut self) { fn jump_to_last_row(&mut self) {
let _view_name = self.model.active_view.clone(); let count = {
if let Some(view) = self.model.active_view() { let view = match self.model.active_view() { Some(v) => v, None => return };
let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect(); let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
let count = row_cats.first() cross_product_strs(&row_cats, &self.model, view).len().saturating_sub(1)
.and_then(|c| self.model.category(c)) };
.map(|c| c.items.len().saturating_sub(1))
.unwrap_or(0);
drop(view);
if let Some(view) = self.model.active_view_mut() { if let Some(view) = self.model.active_view_mut() {
view.selected.0 = count; view.selected.0 = count;
} if count >= view.row_offset + 20 { view.row_offset = count.saturating_sub(19); }
} }
} }
fn jump_to_last_col(&mut self) { fn jump_to_last_col(&mut self) {
let _view_name = self.model.active_view.clone(); let count = {
if let Some(view) = self.model.active_view() { let view = match self.model.active_view() { Some(v) => v, None => return };
let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect(); let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
let count = col_cats.first() cross_product_strs(&col_cats, &self.model, view).len().saturating_sub(1)
.and_then(|c| self.model.category(c)) };
.map(|c| c.items.len().saturating_sub(1))
.unwrap_or(0);
drop(view);
if let Some(view) = self.model.active_view_mut() { if let Some(view) = self.model.active_view_mut() {
view.selected.1 = count; view.selected.1 = count;
} if count >= view.col_offset + 8 { view.col_offset = count.saturating_sub(7); }
} }
} }
@ -1112,9 +1100,9 @@ impl App {
pub fn selected_cell_key(&self) -> Option<CellKey> { pub fn selected_cell_key(&self) -> Option<CellKey> {
let view = self.model.active_view()?; let view = self.model.active_view()?;
let row_cats: Vec<&str> = view.categories_on(Axis::Row); let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
let col_cats: Vec<&str> = view.categories_on(Axis::Column); let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
let page_cats: Vec<&str> = view.categories_on(Axis::Page); let page_cats: Vec<String> = view.categories_on(Axis::Page).into_iter().map(String::from).collect();
let (sel_row, sel_col) = view.selected; let (sel_row, sel_col) = view.selected;
let mut coords = vec![]; let mut coords = vec![];
@ -1125,25 +1113,20 @@ impl App {
let sel = view.page_selection(cat_name) let sel = view.page_selection(cat_name)
.map(String::from) .map(String::from)
.or_else(|| items.first().cloned())?; .or_else(|| items.first().cloned())?;
coords.push((cat_name.to_string(), sel)); coords.push((cat_name.clone(), sel));
} }
for cat_name in &row_cats {
let items: Vec<String> = self.model.category(cat_name) // Use cross-product indexing so multi-category axes resolve correctly.
.map(|c| c.ordered_item_names().into_iter() let row_items = cross_product_strs(&row_cats, &self.model, view);
.filter(|item| !view.is_hidden(cat_name, item)) let row_item = row_items.get(sel_row)?;
.map(String::from).collect()) for (cat, item) in row_cats.iter().zip(row_item.iter()) {
.unwrap_or_default(); coords.push((cat.clone(), item.clone()));
let item = items.get(sel_row)?.clone();
coords.push((cat_name.to_string(), item));
} }
for cat_name in &col_cats {
let items: Vec<String> = self.model.category(cat_name) let col_items = cross_product_strs(&col_cats, &self.model, view);
.map(|c| c.ordered_item_names().into_iter() let col_item = col_items.get(sel_col)?;
.filter(|item| !view.is_hidden(cat_name, item)) for (cat, item) in col_cats.iter().zip(col_item.iter()) {
.map(String::from).collect()) coords.push((cat.clone(), item.clone()));
.unwrap_or_default();
let item = items.get(sel_col)?.clone();
coords.push((cat_name.to_string(), item));
} }
Some(CellKey::new(coords)) Some(CellKey::new(coords))