From d99d22820ef393a529d731db52fdc325353701e6 Mon Sep 17 00:00:00 2001 From: Ed L Date: Tue, 24 Mar 2026 00:12:00 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20extract=20GridLayout=20as=20single?= =?UTF-8?q?=20source=20for=20view=E2=86=92grid=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/persistence/mod.rs | 90 +++++--------------- src/ui/app.rs | 138 +++++++------------------------ src/ui/grid.rs | 180 +++++++++++----------------------------- src/view/layout.rs | 181 +++++++++++++++++++++++++++++++++++++++++ src/view/mod.rs | 2 + 5 files changed, 282 insertions(+), 309 deletions(-) create mode 100644 src/view/layout.rs diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 472d9d5..d0f4ff7 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -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 = view.categories_on(Axis::Row).into_iter().map(String::from).collect(); - let col_cats: Vec = view.categories_on(Axis::Column).into_iter().map(String::from).collect(); - let page_cats: Vec = 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 = 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> = cross_product_items(&row_cats, model, view); - let col_items: Vec> = 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 = page_coords.iter().map(|(c, v)| format!("{c}={v}")).collect(); + // Header row + let row_header = layout.row_cats.join("/"); + let page_label: Vec = 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 = col_items.iter().map(|ci| ci.join("/")).collect(); + let col_labels: Vec = (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> = 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>> = 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> = if col_items.iter().all(|c| c.is_empty()) { - vec![vec![]] + let col_indices: Vec> = 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 = 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 = 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> { - if cats.is_empty() { - return vec![vec![]]; - } - let mut result: Vec> = vec![vec![]]; - for cat_name in cats { - let items: Vec = 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 -} diff --git a/src/ui/app.rs b/src/ui/app.rs index 004b17d..43171f7 100644 --- a/src/ui/app.rs +++ b/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 = view.categories_on(Axis::Row).into_iter().map(String::from).collect(); - let col_cats: Vec = 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 = 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 = 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 = 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 = view.categories_on(Axis::Row).into_iter().map(String::from).collect(); - let col_cats: Vec = view.categories_on(Axis::Column).into_iter().map(String::from).collect(); - let page_cats: Vec = 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> = cross_product_strs(&row_cats, &self.model, view); - let col_items: Vec> = cross_product_strs(&col_cats, &self.model, view); - - let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat| { - let items: Vec = 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 = (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 { let view = self.model.active_view()?; - let row_cats: Vec = view.categories_on(Axis::Row).into_iter().map(String::from).collect(); - let col_cats: Vec = view.categories_on(Axis::Column).into_iter().map(String::from).collect(); - let page_cats: Vec = 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::>()) - .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> { - if cats.is_empty() { - return vec![vec![]]; - } - let mut result: Vec> = vec![vec![]]; - for cat_name in cats { - let items: Vec = 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 -} diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 50d6dba..adf9bc9 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -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> = cross_product_items( - &row_cats, self.model, view, - ); - - // Gather col items — cross-product of all col-axis categories - let col_items: Vec> = 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 = 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$}", 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!("{:= 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!("{: 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> { - if cats.is_empty() { - return vec![vec![]]; - } - let mut result: Vec> = vec![vec![]]; - for &cat_name in cats { - let items: Vec = 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 = page_cats.iter().map(|cat_name| { - let items: Vec = 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 = 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, diff --git a/src/view/layout.rs b/src/view/layout.rs new file mode 100644 index 0000000..18bbe4c --- /dev/null +++ b/src/view/layout.rs @@ -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, + pub col_cats: Vec, + pub page_coords: Vec<(String, String)>, + pub row_items: Vec>, + pub col_items: Vec>, +} + +impl GridLayout { + pub fn new(model: &Model, view: &View) -> Self { + let row_cats: Vec = view.categories_on(Axis::Row) + .into_iter().map(String::from).collect(); + let col_cats: Vec = view.categories_on(Axis::Column) + .into_iter().map(String::from).collect(); + let page_cats: Vec = view.categories_on(Axis::Page) + .into_iter().map(String::from).collect(); + + let page_coords = page_cats.iter().map(|cat| { + let items: Vec = 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 { + 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> { + if cats.is_empty() { + return vec![vec![]]; + } + let mut result: Vec> = vec![vec![]]; + for cat_name in cats { + let items: Vec = 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"); + } +} diff --git a/src/view/mod.rs b/src/view/mod.rs index 5e7a36a..c241190 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -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;