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:
181
src/view/layout.rs
Normal file
181
src/view/layout.rs
Normal file
@ -0,0 +1,181 @@
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::CellKey;
|
||||
use crate::view::{Axis, View};
|
||||
|
||||
/// The resolved 2-D layout of a view: which item tuples appear on each axis,
|
||||
/// what page filter is active, and how to map (row, col) → CellKey.
|
||||
///
|
||||
/// This is the single authoritative place that converts the multi-dimensional
|
||||
/// model into the flat grid consumed by both the terminal renderer and CSV exporter.
|
||||
pub struct GridLayout {
|
||||
pub row_cats: Vec<String>,
|
||||
pub col_cats: Vec<String>,
|
||||
pub page_coords: Vec<(String, String)>,
|
||||
pub row_items: Vec<Vec<String>>,
|
||||
pub col_items: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
impl GridLayout {
|
||||
pub fn new(model: &Model, view: &View) -> Self {
|
||||
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 page_coords = page_cats.iter().map(|cat| {
|
||||
let items: Vec<String> = 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();
|
||||
|
||||
let row_items = cross_product(model, view, &row_cats);
|
||||
let col_items = cross_product(model, view, &col_cats);
|
||||
|
||||
Self { row_cats, col_cats, page_coords, row_items, col_items }
|
||||
}
|
||||
|
||||
pub fn row_count(&self) -> usize { self.row_items.len() }
|
||||
pub fn col_count(&self) -> usize { self.col_items.len() }
|
||||
|
||||
pub fn row_label(&self, row: usize) -> String {
|
||||
self.row_items.get(row).map(|r| r.join("/")).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn col_label(&self, col: usize) -> String {
|
||||
self.col_items.get(col).map(|c| c.join("/")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Build the CellKey for the data cell at (row, col), including the active
|
||||
/// page-axis filter. Returns None if row or col is out of bounds.
|
||||
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
|
||||
let row_item = self.row_items.get(row)?;
|
||||
let col_item = self.col_items.get(col)?;
|
||||
let mut coords = self.page_coords.clone();
|
||||
for (cat, item) in self.row_cats.iter().zip(row_item.iter()) {
|
||||
coords.push((cat.clone(), item.clone()));
|
||||
}
|
||||
for (cat, item) in self.col_cats.iter().zip(col_item.iter()) {
|
||||
coords.push((cat.clone(), item.clone()));
|
||||
}
|
||||
Some(CellKey::new(coords))
|
||||
}
|
||||
}
|
||||
|
||||
/// Cartesian product of visible items across `cats`, in category order.
|
||||
/// Hidden items are excluded. Returns `vec![vec![]]` when `cats` is empty.
|
||||
fn cross_product(model: &Model, view: &View, cats: &[String]) -> 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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::GridLayout;
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
|
||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||
CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect())
|
||||
}
|
||||
|
||||
fn two_cat_model() -> Model {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Type").unwrap();
|
||||
m.add_category("Month").unwrap();
|
||||
for item in ["Food", "Clothing"] { m.category_mut("Type").unwrap().add_item(item); }
|
||||
for item in ["Jan", "Feb"] { m.category_mut("Month").unwrap().add_item(item); }
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_and_col_counts_match_item_counts() {
|
||||
let m = two_cat_model();
|
||||
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||
assert_eq!(layout.row_count(), 2); // Food, Clothing
|
||||
assert_eq!(layout.col_count(), 2); // Jan, Feb
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_key_encodes_correct_coordinates() {
|
||||
let m = two_cat_model();
|
||||
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||
// row 0 = Food, col 1 = Feb
|
||||
let key = layout.cell_key(0, 1).unwrap();
|
||||
assert_eq!(key, coord(&[("Month", "Feb"), ("Type", "Food")]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_key_out_of_bounds_returns_none() {
|
||||
let m = two_cat_model();
|
||||
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||
assert!(layout.cell_key(99, 0).is_none());
|
||||
assert!(layout.cell_key(0, 99).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_key_includes_page_coords() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Type").unwrap();
|
||||
m.add_category("Month").unwrap();
|
||||
m.add_category("Region").unwrap();
|
||||
m.category_mut("Type").unwrap().add_item("Food");
|
||||
m.category_mut("Month").unwrap().add_item("Jan");
|
||||
m.category_mut("Region").unwrap().add_item("East");
|
||||
m.category_mut("Region").unwrap().add_item("West");
|
||||
m.active_view_mut().unwrap().set_page_selection("Region", "West");
|
||||
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||
let key = layout.cell_key(0, 0).unwrap();
|
||||
assert_eq!(key.get("Region"), Some("West"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_key_round_trips_through_model_evaluate() {
|
||||
let mut m = two_cat_model();
|
||||
m.set_cell(
|
||||
coord(&[("Month", "Feb"), ("Type", "Clothing")]),
|
||||
CellValue::Number(42.0),
|
||||
);
|
||||
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||
// Clothing = row 1, Feb = col 1
|
||||
let key = layout.cell_key(1, 1).unwrap();
|
||||
assert_eq!(m.evaluate(&key), CellValue::Number(42.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_join_with_slash_for_multi_cat_axis() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Type").unwrap();
|
||||
m.add_category("Month").unwrap();
|
||||
m.add_category("Year").unwrap();
|
||||
m.category_mut("Type").unwrap().add_item("Food");
|
||||
m.category_mut("Month").unwrap().add_item("Jan");
|
||||
m.category_mut("Year").unwrap().add_item("2025");
|
||||
m.active_view_mut().unwrap().set_axis("Year", crate::view::Axis::Column);
|
||||
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||
assert_eq!(layout.col_label(0), "Jan/2025");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user