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

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