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:
107
src/ui/app.rs
107
src/ui/app.rs
@ -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))
|
if let Some(view) = self.model.active_view_mut() {
|
||||||
.unwrap_or(0);
|
view.selected.0 = count;
|
||||||
drop(view);
|
if count >= view.row_offset + 20 { view.row_offset = count.saturating_sub(19); }
|
||||||
if let Some(view) = self.model.active_view_mut() {
|
|
||||||
view.selected.0 = count;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
if let Some(view) = self.model.active_view_mut() {
|
||||||
.unwrap_or(0);
|
view.selected.1 = count;
|
||||||
drop(view);
|
if count >= view.col_offset + 8 { view.col_offset = count.saturating_sub(7); }
|
||||||
if let Some(view) = self.model.active_view_mut() {
|
|
||||||
view.selected.1 = count;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user