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$}", 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!("{:= 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!("{: 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!("{:= 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 = 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::().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::().trim_end().to_string()) .collect::>() .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}"); } }