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:
180
src/ui/grid.rs
180
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<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,
|
||||
|
||||
Reference in New Issue
Block a user