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
}

View File

@ -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
}

View File

@ -7,8 +7,8 @@ use ratatui::{
use unicode_width::UnicodeWidthStr;
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
use crate::model::cell::CellValue;
use crate::view::GridLayout;
use crate::ui::app::AppMode;
const ROW_HEADER_WIDTH: u16 = 16;
@ -32,110 +32,73 @@ impl<'a> GridWidget<'a> {
None => return,
};
let row_cats: Vec<&str> = view.categories_on(Axis::Row);
let col_cats: Vec<&str> = view.categories_on(Axis::Column);
let page_cats: Vec<&str> = view.categories_on(Axis::Page);
// Gather row items — cross-product of all row-axis categories
let row_items: Vec<Vec<String>> = cross_product_items(
&row_cats, self.model, view,
);
// Gather col items — cross-product of all col-axis categories
let col_items: Vec<Vec<String>> = cross_product_items(
&col_cats, self.model, view,
);
// Page filter coords
let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat_name| {
let items: Vec<String> = self.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.to_string(), sel)
}).collect();
let layout = GridLayout::new(self.model, view);
let (sel_row, sel_col) = view.selected;
let row_offset = view.row_offset;
let col_offset = view.col_offset;
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
// Available cols
let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize;
let visible_col_items: Vec<_> = col_items.iter()
.skip(col_offset)
.take(available_cols.max(1))
.collect();
let visible_col_range = col_offset..(col_offset + available_cols.max(1)).min(layout.col_count());
let available_rows = area.height.saturating_sub(2) as usize; // header + border
let visible_row_items: Vec<_> = row_items.iter()
.skip(row_offset)
.take(available_rows.max(1))
.collect();
let available_rows = area.height.saturating_sub(2) as usize;
let visible_row_range = row_offset..(row_offset + available_rows.max(1)).min(layout.row_count());
let mut y = area.y;
// Column headers
let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let row_header_col = format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize);
buf.set_string(area.x, y, &row_header_col, Style::default());
buf.set_string(area.x, y,
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
Style::default());
let mut x = area.x + ROW_HEADER_WIDTH;
for (ci, col_item) in visible_col_items.iter().enumerate() {
let abs_ci = ci + col_offset;
let label = col_item.join("/");
let styled = if abs_ci == sel_col {
for ci in visible_col_range.clone() {
let label = layout.col_label(ci);
let styled = if ci == sel_col {
header_style.add_modifier(Modifier::UNDERLINED)
} else {
header_style
};
let truncated = truncate(&label, COL_WIDTH as usize);
buf.set_string(x, y, format!("{:>width$}", truncated, width = COL_WIDTH as usize), styled);
buf.set_string(x, y,
format!("{:>width$}", truncate(&label, COL_WIDTH as usize), width = COL_WIDTH as usize),
styled);
x += COL_WIDTH;
if x >= area.x + area.width { break; }
}
y += 1;
// Separator
let sep = "".repeat(area.width as usize);
buf.set_string(area.x, y, &sep, Style::default().fg(Color::DarkGray));
buf.set_string(area.x, y,
"".repeat(area.width as usize),
Style::default().fg(Color::DarkGray));
y += 1;
// Data rows
for (ri, row_item) in visible_row_items.iter().enumerate() {
let abs_ri = ri + row_offset;
for ri in visible_row_range.clone() {
if y >= area.y + area.height { break; }
let row_label = row_item.join("/");
let row_style = if abs_ri == sel_row {
let row_label = layout.row_label(ri);
let row_style = if ri == sel_row {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let row_header_str = truncate(&row_label, ROW_HEADER_WIDTH as usize - 1);
buf.set_string(area.x, y,
format!("{:<width$}", row_header_str, width = ROW_HEADER_WIDTH as usize),
format!("{:<width$}", truncate(&row_label, ROW_HEADER_WIDTH as usize - 1), width = ROW_HEADER_WIDTH as usize),
row_style);
let mut x = area.x + ROW_HEADER_WIDTH;
for (ci, col_item) in visible_col_items.iter().enumerate() {
let abs_ci = ci + col_offset;
for ci in visible_col_range.clone() {
if x >= area.x + area.width { break; }
let mut coords = page_coords.clone();
for (cat, item) in row_cats.iter().zip(row_item.iter()) {
coords.push((cat.to_string(), item.clone()));
}
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
coords.push((cat.to_string(), item.clone()));
}
let key = CellKey::new(coords);
let key = match layout.cell_key(ri, ci) {
Some(k) => k,
None => { x += COL_WIDTH; continue; }
};
let value = self.model.evaluate(&key);
let cell_str = format_value(&value, fmt_comma, fmt_decimals);
let is_selected = abs_ri == sel_row && abs_ci == sel_col;
let is_selected = ri == sel_row && ci == sel_col;
let is_search_match = !self.search_query.is_empty()
&& cell_str.to_lowercase().contains(&self.search_query.to_lowercase());
@ -149,18 +112,18 @@ impl<'a> GridWidget<'a> {
Style::default()
};
let formatted = format!("{:>width$}", truncate(&cell_str, COL_WIDTH as usize), width = COL_WIDTH as usize);
buf.set_string(x, y, formatted, cell_style);
buf.set_string(x, y,
format!("{:>width$}", truncate(&cell_str, COL_WIDTH as usize), width = COL_WIDTH as usize),
cell_style);
x += COL_WIDTH;
}
// Edit indicator
if matches!(self.mode, AppMode::Editing { .. }) && abs_ri == sel_row {
if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row {
if let AppMode::Editing { buffer } = self.mode {
let edit_x = area.x + ROW_HEADER_WIDTH + (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH;
let edit_str = format!("{:<width$}", buffer, width = COL_WIDTH as usize);
buf.set_string(edit_x, y,
truncate(&edit_str, COL_WIDTH as usize),
truncate(&format!("{:<width$}", buffer, width = COL_WIDTH as usize), COL_WIDTH as usize),
Style::default().fg(Color::Green).add_modifier(Modifier::UNDERLINED));
}
}
@ -169,10 +132,11 @@ impl<'a> GridWidget<'a> {
}
// Total row
if !col_items.is_empty() && !row_items.is_empty() {
if layout.row_count() > 0 && layout.col_count() > 0 {
if y < area.y + area.height {
let sep = "".repeat(area.width as usize);
buf.set_string(area.x, y, &sep, Style::default().fg(Color::DarkGray));
buf.set_string(area.x, y,
"".repeat(area.width as usize),
Style::default().fg(Color::DarkGray));
y += 1;
}
if y < area.y + area.height {
@ -181,21 +145,12 @@ impl<'a> GridWidget<'a> {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let mut x = area.x + ROW_HEADER_WIDTH;
for (_ci, col_item) in visible_col_items.iter().enumerate() {
for ci in visible_col_range {
if x >= area.x + area.width { break; }
let mut coords = page_coords.clone();
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
coords.push((cat.to_string(), item.clone()));
}
let total: f64 = row_items.iter().map(|ri| {
let mut c = coords.clone();
for (cat, item) in row_cats.iter().zip(ri.iter()) {
c.push((cat.to_string(), item.clone()));
}
let key = CellKey::new(c);
self.model.evaluate(&key).as_f64().unwrap_or(0.0)
}).sum();
let total: f64 = (0..layout.row_count())
.filter_map(|ri| layout.cell_key(ri, ci))
.map(|key| self.model.evaluate(&key).as_f64().unwrap_or(0.0))
.sum();
let total_str = format_f64(total, fmt_comma, fmt_decimals);
buf.set_string(x, y,
format!("{:>width$}", truncate(&total_str, COL_WIDTH as usize), width = COL_WIDTH as usize),
@ -207,37 +162,6 @@ impl<'a> GridWidget<'a> {
}
}
/// Returns the cross-product of items across `cats`, filtered by hidden state.
/// Each element is a Vec of item names, one per category in `cats` order.
fn cross_product_items(cats: &[&str], 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
}
impl<'a> Widget for GridWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
@ -250,20 +174,12 @@ impl<'a> Widget for GridWidget<'a> {
block.render(area, buf);
// Page axis bar
let view = self.model.active_view();
if let Some(view) = view {
let page_cats: Vec<&str> = view.categories_on(Axis::Page);
if !page_cats.is_empty() && inner.height > 0 {
let page_info: Vec<String> = page_cats.iter().map(|cat_name| {
let items: Vec<String> = self.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_else(|| "(none)".to_string());
format!("{cat_name} = {sel}")
}).collect();
if let Some(view) = self.model.active_view() {
let layout = GridLayout::new(self.model, view);
if !layout.page_coords.is_empty() && inner.height > 0 {
let page_info: Vec<String> = layout.page_coords.iter()
.map(|(cat, sel)| format!("{cat} = {sel}"))
.collect();
let page_str = format!(" [{}] ", page_info.join(" | "));
buf.set_string(inner.x, inner.y,
&page_str,

181
src/view/layout.rs Normal file
View 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");
}
}

View File

@ -1,5 +1,7 @@
pub mod view;
pub mod axis;
pub mod layout;
pub use view::View;
pub use axis::Axis;
pub use layout::GridLayout;