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:
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user