Files
improvise/src/ui/grid.rs
2026-03-31 22:50:10 -07:00

838 lines
30 KiB
Rust

use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget},
};
use unicode_width::UnicodeWidthStr;
use crate::model::cell::CellValue;
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::view::{AxisEntry, GridLayout};
const ROW_HEADER_WIDTH: u16 = 16;
const COL_WIDTH: u16 = 10;
const GROUP_EXPANDED: &str = "";
const GROUP_COLLAPSED: &str = "";
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 = self.model.active_view();
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 n_col_levels = layout.col_cats.len().max(1);
let n_row_levels = layout.row_cats.len().max(1);
// Sub-column widths for row header area
let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16;
let sub_widths: Vec<u16> = (0..n_row_levels)
.map(|d| {
if d < n_row_levels - 1 {
sub_col_w
} else {
ROW_HEADER_WIDTH.saturating_sub(sub_col_w * (n_row_levels as u16 - 1))
}
})
.collect();
// Flat lists of data-only tuples for repeat-suppression in headers
let data_col_items: Vec<&Vec<String>> = layout
.col_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
}
})
.collect();
let data_row_items: Vec<&Vec<String>> = layout
.row_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
}
})
.collect();
// Map each data-col index to its group name (None if ungrouped)
let col_groups: Vec<Option<String>> = {
let mut groups = Vec::new();
let mut current: Option<String> = None;
for entry in &layout.col_items {
match entry {
AxisEntry::GroupHeader { group_name, .. } => current = Some(group_name.clone()),
AxisEntry::DataItem(_) => groups.push(current.clone()),
}
}
groups
};
let has_col_groups = col_groups.iter().any(|g| g.is_some());
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 _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 };
let visual_row_start = layout
.data_row_to_visual(row_offset)
.unwrap_or(layout.row_items.len());
let mut y = area.y;
// Optional column group header row
if has_col_groups {
let group_style = Style::default()
.fg(Color::Green)
.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;
let mut prev_group: Option<&str> = None;
for ci in visible_col_range.clone() {
if x >= area.x + area.width {
break;
}
let group = col_groups[ci].as_deref();
let label = if group != prev_group {
group.unwrap_or("")
} else {
""
};
prev_group = group;
buf.set_string(
x,
y,
format!(
"{:<width$}",
truncate(label, COL_WIDTH as usize),
width = COL_WIDTH as usize
),
group_style,
);
x += COL_WIDTH;
}
y += 1;
}
// Column headers — one row per level, with repeat suppression
let header_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
for d in 0..n_col_levels {
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 = if layout.col_cats.is_empty() {
layout.col_label(ci)
} else {
let show = ci == 0 || data_col_items[ci][..=d] != data_col_items[ci - 1][..=d];
if show {
data_col_items[ci][d].clone()
} else {
String::new()
}
};
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 — iterate visual entries, rendering group headers inline
let group_header_style = Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD);
let mut data_row_idx = row_offset;
for entry in &layout.row_items[visual_row_start..] {
if y >= area.y + area.height {
break;
}
match entry {
AxisEntry::GroupHeader {
cat_name,
group_name,
} => {
let indicator = if view.is_group_collapsed(cat_name, group_name) {
GROUP_COLLAPSED
} else {
GROUP_EXPANDED
};
let label = format!("{indicator} {group_name}");
buf.set_string(
area.x,
y,
format!(
"{:<width$}",
truncate(&label, ROW_HEADER_WIDTH as usize),
width = ROW_HEADER_WIDTH as usize
),
group_header_style,
);
let mut x = area.x + ROW_HEADER_WIDTH;
while x < area.x + area.width {
buf.set_string(
x,
y,
format!("{:─<width$}", "", width = COL_WIDTH as usize),
Style::default().fg(Color::DarkGray),
);
x += COL_WIDTH;
}
}
AxisEntry::DataItem(_) => {
let ri = data_row_idx;
data_row_idx += 1;
let row_style = if ri == sel_row {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
// Multi-level row header — one sub-column per row category
let mut hx = area.x;
for d in 0..n_row_levels {
let sw = sub_widths[d] as usize;
let label = if layout.row_cats.is_empty() {
layout.row_label(ri)
} else {
let show =
ri == 0 || data_row_items[ri][..=d] != data_row_items[ri - 1][..=d];
if show {
data_row_items[ri][d].clone()
} else {
String::new()
}
};
buf.set_string(
hx,
y,
format!("{:<width$}", truncate(&label, sw), width = sw),
row_style,
);
hx += sub_widths[d];
}
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.as_ref(), 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 value.is_none() {
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_f64(&key))
.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
let layout = GridLayout::new(self.model, self.model.active_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: Option<&CellValue>, comma: bool, decimals: u8) -> String {
match v {
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
Some(CellValue::Text(s)) => s.clone(),
None => 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 super::GridWidget;
use crate::formula::parse_formula;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::ui::app::AppMode;
// ── 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");
}
m.active_view_mut().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");
}
m.active_view_mut()
.set_axis("Recipient", crate::view::Axis::Row);
let text = buf_text(&render(&m, 80, 24));
// Multi-level row headers: category values shown separately, not joined with /
assert!(
!text.contains("Food/Alice"),
"slash-joined labels should be gone:\n{text}"
);
assert!(
!text.contains("Clothing/Bob"),
"slash-joined labels should be gone:\n{text}"
);
// Each item name appears on its own
assert!(text.contains("Food"), "expected 'Food' in:\n{text}");
assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}");
assert!(text.contains("Alice"), "expected 'Alice' in:\n{text}");
assert!(text.contains("Bob"), "expected '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");
}
m.active_view_mut()
.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");
}
m.active_view_mut()
.set_axis("Year", crate::view::Axis::Column);
let text = buf_text(&render(&m, 80, 24));
// Multi-level column headers: category values shown separately, not joined with /
assert!(
!text.contains("Jan/2024"),
"slash-joined headers should be gone:\n{text}"
);
assert!(
!text.contains("Jan/2025"),
"slash-joined headers should be gone:\n{text}"
);
assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}");
assert!(text.contains("2024"), "expected '2024' in:\n{text}");
assert!(text.contains("2025"), "expected '2025' in:\n{text}");
}
}