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>
504 lines
20 KiB
Rust
504 lines
20 KiB
Rust
use ratatui::{
|
|
buffer::Buffer,
|
|
layout::Rect,
|
|
style::{Color, Modifier, Style},
|
|
widgets::{Block, Borders, Widget},
|
|
};
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::model::Model;
|
|
use crate::model::cell::CellValue;
|
|
use crate::view::GridLayout;
|
|
use crate::ui::app::AppMode;
|
|
|
|
const ROW_HEADER_WIDTH: u16 = 16;
|
|
const COL_WIDTH: u16 = 10;
|
|
const MIN_COL_WIDTH: u16 = 6;
|
|
|
|
pub struct GridWidget<'a> {
|
|
pub model: &'a Model,
|
|
pub mode: &'a AppMode,
|
|
pub search_query: &'a str,
|
|
}
|
|
|
|
impl<'a> GridWidget<'a> {
|
|
pub fn new(model: &'a Model, mode: &'a AppMode, search_query: &'a str) -> Self {
|
|
Self { model, mode, search_query }
|
|
}
|
|
|
|
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
|
|
let view = match self.model.active_view() {
|
|
Some(v) => v,
|
|
None => return,
|
|
};
|
|
|
|
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);
|
|
|
|
let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize;
|
|
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;
|
|
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);
|
|
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 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
|
|
};
|
|
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
|
|
buf.set_string(area.x, y,
|
|
"─".repeat(area.width as usize),
|
|
Style::default().fg(Color::DarkGray));
|
|
y += 1;
|
|
|
|
// Data rows
|
|
for ri in visible_row_range.clone() {
|
|
if y >= area.y + area.height { break; }
|
|
|
|
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()
|
|
};
|
|
buf.set_string(area.x, y,
|
|
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 in visible_col_range.clone() {
|
|
if x >= area.x + area.width { break; }
|
|
|
|
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 = 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());
|
|
|
|
let cell_style = if is_selected {
|
|
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
|
} else if is_search_match {
|
|
Style::default().fg(Color::Black).bg(Color::Yellow)
|
|
} else if matches!(value, CellValue::Empty) {
|
|
Style::default().fg(Color::DarkGray)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
|
|
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 { .. }) && 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;
|
|
buf.set_string(edit_x, y,
|
|
truncate(&format!("{:<width$}", buffer, width = COL_WIDTH as usize), COL_WIDTH as usize),
|
|
Style::default().fg(Color::Green).add_modifier(Modifier::UNDERLINED));
|
|
}
|
|
}
|
|
|
|
y += 1;
|
|
}
|
|
|
|
// Total row
|
|
if layout.row_count() > 0 && layout.col_count() > 0 {
|
|
if y < area.y + area.height {
|
|
buf.set_string(area.x, y,
|
|
"─".repeat(area.width as usize),
|
|
Style::default().fg(Color::DarkGray));
|
|
y += 1;
|
|
}
|
|
if y < area.y + area.height {
|
|
buf.set_string(area.x, y,
|
|
format!("{:<width$}", "Total", width = ROW_HEADER_WIDTH as usize),
|
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
|
|
|
let mut x = area.x + ROW_HEADER_WIDTH;
|
|
for ci in visible_col_range {
|
|
if x >= area.x + area.width { break; }
|
|
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),
|
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
|
x += COL_WIDTH;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
impl<'a> Widget for GridWidget<'a> {
|
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
let view_name = self.model.active_view
|
|
.clone();
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.title(format!(" View: {} ", view_name));
|
|
let inner = block.inner(area);
|
|
block.render(area, buf);
|
|
|
|
// Page axis bar
|
|
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,
|
|
Style::default().fg(Color::Magenta));
|
|
|
|
let grid_area = Rect {
|
|
y: inner.y + 1,
|
|
height: inner.height.saturating_sub(1),
|
|
..inner
|
|
};
|
|
self.render_grid(grid_area, buf);
|
|
} else {
|
|
self.render_grid(inner, buf);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn format_value(v: &CellValue, comma: bool, decimals: u8) -> String {
|
|
match v {
|
|
CellValue::Number(n) => format_f64(*n, comma, decimals),
|
|
CellValue::Text(s) => s.clone(),
|
|
CellValue::Empty => String::new(),
|
|
}
|
|
}
|
|
|
|
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
|
|
let comma = fmt.contains(',');
|
|
let decimals = fmt.rfind('.')
|
|
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
|
|
.unwrap_or(0);
|
|
(comma, decimals)
|
|
}
|
|
|
|
pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
|
|
let formatted = format!("{:.prec$}", n, prec = decimals as usize);
|
|
if !comma {
|
|
return formatted;
|
|
}
|
|
// Split integer and decimal parts
|
|
let (int_part, dec_part) = if let Some(dot) = formatted.find('.') {
|
|
(&formatted[..dot], Some(&formatted[dot..]))
|
|
} else {
|
|
(&formatted[..], None)
|
|
};
|
|
let is_neg = int_part.starts_with('-');
|
|
let digits = if is_neg { &int_part[1..] } else { int_part };
|
|
let mut result = String::new();
|
|
for (idx, c) in digits.chars().rev().enumerate() {
|
|
if idx > 0 && idx % 3 == 0 { result.push(','); }
|
|
result.push(c);
|
|
}
|
|
if is_neg { result.push('-'); }
|
|
let mut out: String = result.chars().rev().collect();
|
|
if let Some(dec) = dec_part {
|
|
out.push_str(dec);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn truncate(s: &str, max_width: usize) -> String {
|
|
let w = s.width();
|
|
if w <= max_width {
|
|
s.to_string()
|
|
} else if max_width > 1 {
|
|
let mut result = String::new();
|
|
let mut width = 0;
|
|
for c in s.chars() {
|
|
let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
|
|
if width + cw + 1 > max_width { break; }
|
|
result.push(c);
|
|
width += cw;
|
|
}
|
|
result.push('…');
|
|
result
|
|
} else {
|
|
s.chars().take(max_width).collect()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
|
|
|
|
use crate::model::Model;
|
|
use crate::model::cell::{CellKey, CellValue};
|
|
use crate::formula::parse_formula;
|
|
use crate::ui::app::AppMode;
|
|
use super::GridWidget;
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
/// Render a GridWidget into a fresh buffer of the given size.
|
|
fn render(model: &Model, width: u16, height: u16) -> Buffer {
|
|
let area = Rect::new(0, 0, width, height);
|
|
let mut buf = Buffer::empty(area);
|
|
GridWidget::new(model, &AppMode::Normal, "").render(area, &mut buf);
|
|
buf
|
|
}
|
|
|
|
/// Flatten the buffer to a multiline string, trailing spaces trimmed per row.
|
|
fn buf_text(buf: &Buffer) -> String {
|
|
let w = buf.area().width as usize;
|
|
buf.content()
|
|
.chunks(w)
|
|
.map(|row| row.iter().map(|c| c.symbol()).collect::<String>().trim_end().to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
|
CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect())
|
|
}
|
|
|
|
/// Minimal model: Type on Row, Month on Column.
|
|
fn two_cat_model() -> Model {
|
|
let mut m = Model::new("Test");
|
|
m.add_category("Type").unwrap(); // → Row
|
|
m.add_category("Month").unwrap(); // → Column
|
|
if let Some(c) = m.category_mut("Type") {
|
|
c.add_item("Food");
|
|
c.add_item("Clothing");
|
|
}
|
|
if let Some(c) = m.category_mut("Month") {
|
|
c.add_item("Jan");
|
|
c.add_item("Feb");
|
|
}
|
|
m
|
|
}
|
|
|
|
// ── Column headers ────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn column_headers_appear() {
|
|
let m = two_cat_model();
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}");
|
|
assert!(text.contains("Feb"), "expected 'Feb' in:\n{text}");
|
|
}
|
|
|
|
// ── Row headers ───────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn row_headers_appear() {
|
|
let m = two_cat_model();
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
assert!(text.contains("Food"), "expected 'Food' in:\n{text}");
|
|
assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}");
|
|
}
|
|
|
|
// ── Cell values ───────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn cell_value_appears_in_correct_position() {
|
|
let mut m = two_cat_model();
|
|
m.set_cell(coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(123.0));
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
assert!(text.contains("123"), "expected '123' in:\n{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_cell_values_all_appear() {
|
|
let mut m = two_cat_model();
|
|
m.set_cell(coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(100.0));
|
|
m.set_cell(coord(&[("Type", "Food"), ("Month", "Feb")]), CellValue::Number(200.0));
|
|
m.set_cell(coord(&[("Type", "Clothing"), ("Month", "Jan")]), CellValue::Number(50.0));
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
assert!(text.contains("100"), "expected '100' in:\n{text}");
|
|
assert!(text.contains("200"), "expected '200' in:\n{text}");
|
|
assert!(text.contains("50"), "expected '50' in:\n{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn unset_cells_show_no_value() {
|
|
let m = two_cat_model();
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
// No digits should appear in the data area if nothing is set
|
|
// (Total row shows "0" — exclude that from this check by looking for non-zero)
|
|
assert!(!text.contains("100"), "unexpected '100' in:\n{text}");
|
|
}
|
|
|
|
// ── Total row ─────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn total_row_label_appears() {
|
|
let m = two_cat_model();
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
assert!(text.contains("Total"), "expected 'Total' in:\n{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn total_row_sums_column_correctly() {
|
|
let mut m = two_cat_model();
|
|
m.set_cell(coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(100.0));
|
|
m.set_cell(coord(&[("Type", "Clothing"), ("Month", "Jan")]), CellValue::Number(50.0));
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
// Food(100) + Clothing(50) = 150 for Jan
|
|
assert!(text.contains("150"), "expected '150' (total for Jan) in:\n{text}");
|
|
}
|
|
|
|
// ── Page filter bar ───────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn page_filter_bar_shows_category_and_selection() {
|
|
let mut m = Model::new("Test");
|
|
m.add_category("Type").unwrap(); // → Row
|
|
m.add_category("Month").unwrap(); // → Column
|
|
m.add_category("Payer").unwrap(); // → Page
|
|
if let Some(c) = m.category_mut("Type") { c.add_item("Food"); }
|
|
if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); }
|
|
if let Some(c) = m.category_mut("Payer") {
|
|
c.add_item("Alice");
|
|
c.add_item("Bob");
|
|
}
|
|
if let Some(v) = m.active_view_mut() {
|
|
v.set_page_selection("Payer", "Bob");
|
|
}
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
assert!(text.contains("Payer = Bob"), "expected 'Payer = Bob' in:\n{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn page_filter_defaults_to_first_item() {
|
|
let mut m = Model::new("Test");
|
|
m.add_category("Type").unwrap();
|
|
m.add_category("Month").unwrap();
|
|
m.add_category("Payer").unwrap();
|
|
if let Some(c) = m.category_mut("Type") { c.add_item("Food"); }
|
|
if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); }
|
|
if let Some(c) = m.category_mut("Payer") {
|
|
c.add_item("Alice");
|
|
c.add_item("Bob");
|
|
}
|
|
// No explicit selection — should default to first item
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
assert!(text.contains("Payer = Alice"), "expected 'Payer = Alice' in:\n{text}");
|
|
}
|
|
|
|
// ── Formula evaluation ────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn formula_cell_renders_computed_value() {
|
|
let mut m = Model::new("Test");
|
|
m.add_category("Measure").unwrap(); // → Row
|
|
m.add_category("Region").unwrap(); // → Column
|
|
if let Some(c) = m.category_mut("Measure") {
|
|
c.add_item("Revenue");
|
|
c.add_item("Cost");
|
|
c.add_item("Profit");
|
|
}
|
|
if let Some(c) = m.category_mut("Region") { c.add_item("East"); }
|
|
m.set_cell(coord(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(1000.0));
|
|
m.set_cell(coord(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(600.0));
|
|
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
|
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
assert!(text.contains("400"), "expected '400' (Profit) in:\n{text}");
|
|
}
|
|
|
|
// ── Multiple categories on same axis (cross-product) ─────────────────────
|
|
|
|
#[test]
|
|
fn two_row_categories_produce_cross_product_labels() {
|
|
let mut m = Model::new("Test");
|
|
m.add_category("Type").unwrap(); // → Row
|
|
m.add_category("Month").unwrap(); // → Column
|
|
m.add_category("Recipient").unwrap(); // → Page by default; move to Row
|
|
if let Some(c) = m.category_mut("Type") { c.add_item("Food"); c.add_item("Clothing"); }
|
|
if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); }
|
|
if let Some(c) = m.category_mut("Recipient") { c.add_item("Alice"); c.add_item("Bob"); }
|
|
if let Some(v) = m.active_view_mut() {
|
|
v.set_axis("Recipient", crate::view::Axis::Row);
|
|
}
|
|
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
// Cross-product rows: Food/Alice, Food/Bob, Clothing/Alice, Clothing/Bob
|
|
assert!(text.contains("Food/Alice"), "expected 'Food/Alice' in:\n{text}");
|
|
assert!(text.contains("Food/Bob"), "expected 'Food/Bob' in:\n{text}");
|
|
assert!(text.contains("Clothing/Alice"), "expected 'Clothing/Alice' in:\n{text}");
|
|
assert!(text.contains("Clothing/Bob"), "expected 'Clothing/Bob' in:\n{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn two_row_categories_include_all_coords_in_cell_lookup() {
|
|
let mut m = Model::new("Test");
|
|
m.add_category("Type").unwrap();
|
|
m.add_category("Month").unwrap();
|
|
m.add_category("Recipient").unwrap();
|
|
if let Some(c) = m.category_mut("Type") { c.add_item("Food"); }
|
|
if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); }
|
|
if let Some(c) = m.category_mut("Recipient") { c.add_item("Alice"); c.add_item("Bob"); }
|
|
if let Some(v) = m.active_view_mut() {
|
|
v.set_axis("Recipient", crate::view::Axis::Row);
|
|
}
|
|
// Set data at the full 3-coordinate key
|
|
m.set_cell(
|
|
coord(&[("Month", "Jan"), ("Recipient", "Alice"), ("Type", "Food")]),
|
|
CellValue::Number(77.0),
|
|
);
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
assert!(text.contains("77"), "expected '77' in:\n{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn two_column_categories_produce_cross_product_headers() {
|
|
let mut m = Model::new("Test");
|
|
m.add_category("Type").unwrap(); // → Row
|
|
m.add_category("Month").unwrap(); // → Column
|
|
m.add_category("Year").unwrap(); // → Page by default; move to Column
|
|
if let Some(c) = m.category_mut("Type") { c.add_item("Food"); }
|
|
if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); }
|
|
if let Some(c) = m.category_mut("Year") { c.add_item("2024"); c.add_item("2025"); }
|
|
if let Some(v) = m.active_view_mut() {
|
|
v.set_axis("Year", crate::view::Axis::Column);
|
|
}
|
|
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
assert!(text.contains("Jan/2024"), "expected 'Jan/2024' in:\n{text}");
|
|
assert!(text.contains("Jan/2025"), "expected 'Jan/2025' in:\n{text}");
|
|
}
|
|
}
|