refactor: extract GridLayout as single source for view→grid mapping
Three copies of cross_product existed (grid.rs, app.rs, persistence/mod.rs) with slightly different signatures. Extracted into GridLayout in src/view/layout.rs, which is now the single canonical mapping from a View to a 2-D grid: row/col counts, labels, and cell_key(row, col) → CellKey. All consumers updated to use GridLayout::new(model, view): - grid.rs: render_grid, total-row computation, page bar - persistence/mod.rs: export_csv - app.rs: move_selection, jump_to_last_row/col, scroll_rows, search_navigate, selected_cell_key Also includes two app.rs UI bug fixes that were discovered while refactoring: - Ctrl+Arrow tile movement was unreachable (shadowed by plain arrow arms); moved before plain arrow handlers - RemoveFormula dispatch now passes target_category (required by the formula management fix in the previous commit) GridLayout has 6 unit tests covering counts, label formatting, cell_key correctness, out-of-bounds, page coord inclusion, and evaluate round-trip. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
138
src/ui/app.rs
138
src/ui/app.rs
@ -7,7 +7,7 @@ use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::import::wizard::{ImportWizard, WizardStep};
|
||||
use crate::persistence;
|
||||
use crate::view::Axis;
|
||||
use crate::view::{Axis, GridLayout};
|
||||
use crate::command::{self, Command};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@ -176,6 +176,17 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tile movement (Ctrl+Arrow) — must come before plain arrows ──
|
||||
(KeyCode::Left, KeyModifiers::CONTROL)
|
||||
| (KeyCode::Right, KeyModifiers::CONTROL)
|
||||
| (KeyCode::Up, KeyModifiers::CONTROL)
|
||||
| (KeyCode::Down, KeyModifiers::CONTROL) => {
|
||||
let count = self.model.category_names().len();
|
||||
if count > 0 {
|
||||
self.mode = AppMode::TileSelect { cat_idx: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────
|
||||
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
|
||||
self.move_selection(-1, 0);
|
||||
@ -279,17 +290,6 @@ impl App {
|
||||
self.mode = AppMode::TileSelect { cat_idx: 0 };
|
||||
}
|
||||
}
|
||||
// Legacy Ctrl+Arrow still works
|
||||
(KeyCode::Left, KeyModifiers::CONTROL)
|
||||
| (KeyCode::Right, KeyModifiers::CONTROL)
|
||||
| (KeyCode::Up, KeyModifiers::CONTROL)
|
||||
| (KeyCode::Down, KeyModifiers::CONTROL) => {
|
||||
let count = self.model.category_names().len();
|
||||
if count > 0 {
|
||||
self.mode = AppMode::TileSelect { cat_idx: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Page axis ──────────────────────────────────────────────────
|
||||
(KeyCode::Char('['), _) => { self.page_prev(); }
|
||||
(KeyCode::Char(']'), _) => { self.page_next(); }
|
||||
@ -606,8 +606,10 @@ impl App {
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete => {
|
||||
if self.formula_cursor < self.model.formulas.len() {
|
||||
let target = self.model.formulas[self.formula_cursor].target.clone();
|
||||
command::dispatch(&mut self.model, &Command::RemoveFormula { target });
|
||||
let f = &self.model.formulas[self.formula_cursor];
|
||||
let target = f.target.clone();
|
||||
let target_category = f.target_category.clone();
|
||||
command::dispatch(&mut self.model, &Command::RemoveFormula { target, target_category });
|
||||
if self.formula_cursor > 0 { self.formula_cursor -= 1; }
|
||||
self.dirty = true;
|
||||
}
|
||||
@ -915,14 +917,10 @@ impl App {
|
||||
// ── Motion helpers ───────────────────────────────────────────────────────
|
||||
|
||||
fn move_selection(&mut self, dr: i32, dc: i32) {
|
||||
// Use cross-product counts so multi-category axes navigate correctly.
|
||||
let (row_max, col_max) = {
|
||||
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 col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
|
||||
let rm = cross_product_strs(&row_cats, &self.model, view).len().saturating_sub(1);
|
||||
let cm = cross_product_strs(&col_cats, &self.model, view).len().saturating_sub(1);
|
||||
(rm, cm)
|
||||
let layout = GridLayout::new(&self.model, view);
|
||||
(layout.row_count().saturating_sub(1), layout.col_count().saturating_sub(1))
|
||||
};
|
||||
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
@ -941,8 +939,7 @@ impl App {
|
||||
fn jump_to_last_row(&mut self) {
|
||||
let count = {
|
||||
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();
|
||||
cross_product_strs(&row_cats, &self.model, view).len().saturating_sub(1)
|
||||
GridLayout::new(&self.model, view).row_count().saturating_sub(1)
|
||||
};
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.selected.0 = count;
|
||||
@ -953,8 +950,7 @@ impl App {
|
||||
fn jump_to_last_col(&mut self) {
|
||||
let count = {
|
||||
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();
|
||||
cross_product_strs(&col_cats, &self.model, view).len().saturating_sub(1)
|
||||
GridLayout::new(&self.model, view).col_count().saturating_sub(1)
|
||||
};
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.selected.1 = count;
|
||||
@ -965,8 +961,7 @@ impl App {
|
||||
fn scroll_rows(&mut self, delta: i32) {
|
||||
let row_max = {
|
||||
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();
|
||||
cross_product_strs(&row_cats, &self.model, view).len().saturating_sub(1)
|
||||
GridLayout::new(&self.model, view).row_count().saturating_sub(1)
|
||||
};
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
let nr = (view.selected.0 as i32 + delta).clamp(0, row_max as i32) as usize;
|
||||
@ -986,45 +981,21 @@ impl App {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
};
|
||||
let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
|
||||
let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
|
||||
let page_cats: Vec<String> = view.categories_on(Axis::Page).into_iter().map(String::from).collect();
|
||||
let layout = GridLayout::new(&self.model, view);
|
||||
let (cur_row, cur_col) = view.selected;
|
||||
|
||||
// Build cross-product for rows and cols (inline, avoids circular dep with grid.rs)
|
||||
let row_items: Vec<Vec<String>> = cross_product_strs(&row_cats, &self.model, view);
|
||||
let col_items: Vec<Vec<String>> = cross_product_strs(&col_cats, &self.model, view);
|
||||
|
||||
let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat| {
|
||||
let items: Vec<String> = self.model.category(cat)
|
||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
let sel = view.page_selection(cat)
|
||||
.map(String::from)
|
||||
.or_else(|| items.first().cloned())
|
||||
.unwrap_or_default();
|
||||
(cat.clone(), sel)
|
||||
}).collect();
|
||||
|
||||
// Enumerate all (row_idx, col_idx) grid positions
|
||||
let total_rows = row_items.len().max(1);
|
||||
let total_cols = col_items.len().max(1);
|
||||
let total_rows = layout.row_count().max(1);
|
||||
let total_cols = layout.col_count().max(1);
|
||||
let total = total_rows * total_cols;
|
||||
let cur_flat = cur_row * total_cols + cur_col;
|
||||
|
||||
let matches: Vec<usize> = (0..total).filter(|&flat| {
|
||||
let ri = flat / total_cols;
|
||||
let ci = flat % total_cols;
|
||||
let row_item = row_items.get(ri).cloned().unwrap_or_default();
|
||||
let col_item = col_items.get(ci).cloned().unwrap_or_default();
|
||||
let mut coords = page_coords.clone();
|
||||
for (cat, item) in row_cats.iter().zip(row_item.iter()) {
|
||||
coords.push((cat.clone(), item.clone()));
|
||||
}
|
||||
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
|
||||
coords.push((cat.clone(), item.clone()));
|
||||
}
|
||||
let key = CellKey::new(coords);
|
||||
let key = match layout.cell_key(ri, ci) {
|
||||
Some(k) => k,
|
||||
None => return false,
|
||||
};
|
||||
let val = self.model.evaluate(&key);
|
||||
let s = match &val {
|
||||
CellValue::Number(n) => format!("{n}"),
|
||||
@ -1130,36 +1101,8 @@ impl App {
|
||||
|
||||
pub fn selected_cell_key(&self) -> Option<CellKey> {
|
||||
let view = self.model.active_view()?;
|
||||
let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
|
||||
let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
|
||||
let page_cats: Vec<String> = view.categories_on(Axis::Page).into_iter().map(String::from).collect();
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
let mut coords = vec![];
|
||||
|
||||
for cat_name in &page_cats {
|
||||
let items = self.model.category(cat_name)
|
||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
let sel = view.page_selection(cat_name)
|
||||
.map(String::from)
|
||||
.or_else(|| items.first().cloned())?;
|
||||
coords.push((cat_name.clone(), sel));
|
||||
}
|
||||
|
||||
// Use cross-product indexing so multi-category axes resolve correctly.
|
||||
let row_items = cross_product_strs(&row_cats, &self.model, view);
|
||||
let row_item = row_items.get(sel_row)?;
|
||||
for (cat, item) in row_cats.iter().zip(row_item.iter()) {
|
||||
coords.push((cat.clone(), item.clone()));
|
||||
}
|
||||
|
||||
let col_items = cross_product_strs(&col_cats, &self.model, view);
|
||||
let col_item = col_items.get(sel_col)?;
|
||||
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
|
||||
coords.push((cat.clone(), item.clone()));
|
||||
}
|
||||
|
||||
Some(CellKey::new(coords))
|
||||
GridLayout::new(&self.model, view).cell_key(sel_row, sel_col)
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────
|
||||
@ -1209,26 +1152,3 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the Cartesian product of items from `cats` in the active view,
|
||||
/// filtering hidden items. Returns `vec![vec![]]` when `cats` is empty.
|
||||
fn cross_product_strs(cats: &[String], model: &crate::model::Model, view: &crate::view::View) -> Vec<Vec<String>> {
|
||||
if cats.is_empty() {
|
||||
return vec![vec![]];
|
||||
}
|
||||
let mut result: Vec<Vec<String>> = vec![vec![]];
|
||||
for cat_name in cats {
|
||||
let items: Vec<String> = model.category(cat_name)
|
||||
.map(|c| c.ordered_item_names().into_iter()
|
||||
.filter(|item| !view.is_hidden(cat_name, item))
|
||||
.map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
result = result.into_iter().flat_map(|prefix| {
|
||||
items.iter().map(move |item| {
|
||||
let mut row = prefix.clone();
|
||||
row.push(item.clone());
|
||||
row
|
||||
})
|
||||
}).collect();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user