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:
Ed L
2026-03-24 00:12:00 -07:00
parent 9ef0a72894
commit d99d22820e
5 changed files with 282 additions and 309 deletions

View File

@ -6,7 +6,7 @@ use flate2::write::GzEncoder;
use flate2::Compression;
use crate::model::Model;
use crate::view::View;
use crate::view::GridLayout;
const MAGIC: &str = ".improv";
const COMPRESSED_EXT: &str = ".improv.gz";
@ -57,36 +57,17 @@ pub fn autosave_path(path: &Path) -> std::path::PathBuf {
}
pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
use crate::view::Axis;
let view = model.views.get(view_name)
.ok_or_else(|| anyhow::anyhow!("View '{view_name}' not found"))?;
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();
// Build page-axis coords from current selections (or first item)
let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat_name| {
let items: Vec<String> = model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
let sel = view.page_selection(cat_name)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_default();
(cat_name.clone(), sel)
}).collect();
// Cross-product of all row-axis categories (same logic as grid renderer)
let row_items: Vec<Vec<String>> = cross_product_items(&row_cats, model, view);
let col_items: Vec<Vec<String>> = cross_product_items(&col_cats, model, view);
let layout = GridLayout::new(model, view);
let mut out = String::new();
// Header row: row-label columns then col-item labels
let row_header = if row_cats.is_empty() { String::new() } else { row_cats.join("/") };
let page_label: Vec<String> = page_coords.iter().map(|(c, v)| format!("{c}={v}")).collect();
// Header row
let row_header = layout.row_cats.join("/");
let page_label: Vec<String> = layout.page_coords.iter()
.map(|(c, v)| format!("{c}={v}")).collect();
let header_prefix = if page_label.is_empty() { row_header } else {
format!("{} ({})", row_header, page_label.join(", "))
};
@ -94,38 +75,35 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
out.push_str(&header_prefix);
out.push(',');
}
let col_labels: Vec<String> = col_items.iter().map(|ci| ci.join("/")).collect();
let col_labels: Vec<String> = (0..layout.col_count()).map(|ci| layout.col_label(ci)).collect();
out.push_str(&col_labels.join(","));
out.push('\n');
// Data rows
let effective_row_items: Vec<Vec<String>> = if row_items.iter().all(|r| r.is_empty()) {
vec![vec![]]
// Data rows — treat zero-item axes as a single empty placeholder row/col
let row_range: Box<dyn Iterator<Item = Option<usize>>> = if layout.row_count() == 0 {
Box::new(std::iter::once(None))
} else {
row_items
Box::new((0..layout.row_count()).map(Some))
};
let effective_col_items: Vec<Vec<String>> = if col_items.iter().all(|c| c.is_empty()) {
vec![vec![]]
let col_indices: Vec<Option<usize>> = if layout.col_count() == 0 {
vec![None]
} else {
col_items
(0..layout.col_count()).map(Some).collect()
};
for row_item in &effective_row_items {
let row_label = row_item.join("/");
for row_opt in row_range {
let row_label = row_opt.map(|ri| layout.row_label(ri)).unwrap_or_default();
if !row_label.is_empty() {
out.push_str(&row_label);
out.push(',');
}
let row_values: Vec<String> = effective_col_items.iter().map(|col_item| {
let mut coords = page_coords.clone();
for (cat, item) in row_cats.iter().zip(row_item.iter()) {
coords.push((cat.clone(), item.clone()));
let row_values: Vec<String> = col_indices.iter().map(|&col_opt| {
match (row_opt, col_opt) {
(Some(ri), Some(ci)) => layout.cell_key(ri, ci)
.map(|key| model.evaluate(&key).to_string())
.unwrap_or_default(),
_ => String::new(),
}
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
coords.push((cat.clone(), item.clone()));
}
let key = crate::model::CellKey::new(coords);
model.evaluate(&key).to_string()
}).collect();
out.push_str(&row_values.join(","));
out.push('\n');
@ -134,27 +112,3 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
std::fs::write(path, out)?;
Ok(())
}
/// Compute the Cartesian product of items from `cats` filtered by hidden state in the view.
/// Returns `vec![vec![]]` when `cats` is empty.
fn cross_product_items(cats: &[String], model: &Model, 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
}