feat: group-aware grid rendering and hide/show item
Builds out two half-finished view features: Group collapse: - AxisEntry enum distinguishes GroupHeader from DataItem on grid axes - expand_category() emits group headers and filters collapsed items - Grid renders inline group header rows with ▼/▶ indicator - `z` keybinding toggles collapse of nearest group above cursor Hide/show item: - Restore show_item() (was commented out alongside hide_item) - Add HideItem / ShowItem commands and dispatch - `H` keybinding hides the current row item - `:show-item <cat> <item>` command to restore hidden items - Restore silenced test assertions for hide/show round-trip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
629
src/ui/grid.rs
629
src/ui/grid.rs
@ -6,13 +6,15 @@ use ratatui::{
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::view::GridLayout;
|
||||
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,
|
||||
@ -22,7 +24,11 @@ pub struct GridWidget<'a> {
|
||||
|
||||
impl<'a> GridWidget<'a> {
|
||||
pub fn new(model: &'a Model, mode: &'a AppMode, search_query: &'a str) -> Self {
|
||||
Self { model, mode, search_query }
|
||||
Self {
|
||||
model,
|
||||
mode,
|
||||
search_query,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
|
||||
@ -39,150 +45,352 @@ impl<'a> GridWidget<'a> {
|
||||
|
||||
// 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();
|
||||
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 visible_col_range =
|
||||
col_offset..(col_offset + available_cols.max(1)).min(layout.col_count());
|
||||
|
||||
let header_rows = n_col_levels as u16 + 1; // +1 for separator
|
||||
let available_rows = area.height.saturating_sub(header_rows) as usize;
|
||||
let visible_row_range = row_offset..(row_offset + available_rows.max(1)).min(layout.row_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;
|
||||
|
||||
// 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,
|
||||
// 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());
|
||||
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
|
||||
|| layout.col_items[ci][..=d] != layout.col_items[ci - 1][..=d];
|
||||
if show { layout.col_items[ci][d].clone() } else { String::new() }
|
||||
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);
|
||||
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; }
|
||||
if x >= area.x + area.width {
|
||||
break;
|
||||
}
|
||||
}
|
||||
y += 1;
|
||||
}
|
||||
|
||||
// Separator
|
||||
buf.set_string(area.x, y,
|
||||
buf.set_string(
|
||||
area.x,
|
||||
y,
|
||||
"─".repeat(area.width as usize),
|
||||
Style::default().fg(Color::DarkGray));
|
||||
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_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
|
||||
|| layout.row_items[ri][..=d] != layout.row_items[ri - 1][..=d];
|
||||
if show { layout.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];
|
||||
// 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 mut x = area.x + ROW_HEADER_WIDTH;
|
||||
for ci in visible_col_range.clone() {
|
||||
if x >= area.x + area.width { break; }
|
||||
let row_style = if ri == sel_row {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let key = match layout.cell_key(ri, ci) {
|
||||
Some(k) => k,
|
||||
None => { x += COL_WIDTH; continue; }
|
||||
};
|
||||
let value = self.model.evaluate(&key);
|
||||
// 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 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 mut x = area.x + ROW_HEADER_WIDTH;
|
||||
for ci in visible_col_range.clone() {
|
||||
if x >= area.x + area.width {
|
||||
break;
|
||||
}
|
||||
|
||||
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()
|
||||
};
|
||||
let key = match layout.cell_key(ri, ci) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
x += COL_WIDTH;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let value = self.model.evaluate(&key);
|
||||
|
||||
buf.set_string(x, y,
|
||||
format!("{:>width$}", truncate(&cell_str, COL_WIDTH as usize), width = COL_WIDTH as usize),
|
||||
cell_style);
|
||||
x += COL_WIDTH;
|
||||
}
|
||||
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());
|
||||
|
||||
// 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));
|
||||
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,
|
||||
buf.set_string(
|
||||
area.x,
|
||||
y,
|
||||
"─".repeat(area.width as usize),
|
||||
Style::default().fg(Color::DarkGray));
|
||||
Style::default().fg(Color::DarkGray),
|
||||
);
|
||||
y += 1;
|
||||
}
|
||||
if y < area.y + area.height {
|
||||
buf.set_string(area.x, y,
|
||||
buf.set_string(
|
||||
area.x,
|
||||
y,
|
||||
format!("{:<width$}", "Total", width = ROW_HEADER_WIDTH as usize),
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
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; }
|
||||
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));
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -190,11 +398,9 @@ impl<'a> GridWidget<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl<'a> Widget for GridWidget<'a> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let view_name = self.model.active_view
|
||||
.clone();
|
||||
let view_name = self.model.active_view.clone();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" View: {} ", view_name));
|
||||
@ -204,13 +410,18 @@ impl<'a> Widget for GridWidget<'a> {
|
||||
// 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()
|
||||
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,
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
inner.y,
|
||||
&page_str,
|
||||
Style::default().fg(Color::Magenta));
|
||||
Style::default().fg(Color::Magenta),
|
||||
);
|
||||
|
||||
let grid_area = Rect {
|
||||
y: inner.y + 1,
|
||||
@ -234,7 +445,8 @@ fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
|
||||
|
||||
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
|
||||
let comma = fmt.contains(',');
|
||||
let decimals = fmt.rfind('.')
|
||||
let decimals = fmt
|
||||
.rfind('.')
|
||||
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
|
||||
.unwrap_or(0);
|
||||
(comma, decimals)
|
||||
@ -255,10 +467,14 @@ pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
|
||||
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(','); }
|
||||
if idx > 0 && idx % 3 == 0 {
|
||||
result.push(',');
|
||||
}
|
||||
result.push(c);
|
||||
}
|
||||
if is_neg { result.push('-'); }
|
||||
if is_neg {
|
||||
result.push('-');
|
||||
}
|
||||
let mut out: String = result.chars().rev().collect();
|
||||
if let Some(dec) = dec_part {
|
||||
out.push_str(dec);
|
||||
@ -275,7 +491,9 @@ fn truncate(s: &str, max_width: usize) -> String {
|
||||
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; }
|
||||
if width + cw + 1 > max_width {
|
||||
break;
|
||||
}
|
||||
result.push(c);
|
||||
width += cw;
|
||||
}
|
||||
@ -290,11 +508,11 @@ fn truncate(s: &str, max_width: usize) -> String {
|
||||
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;
|
||||
use crate::formula::parse_formula;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@ -311,20 +529,31 @@ mod tests {
|
||||
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())
|
||||
.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())
|
||||
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
|
||||
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");
|
||||
@ -342,8 +571,8 @@ mod tests {
|
||||
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}");
|
||||
assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}");
|
||||
assert!(text.contains("Feb"), "expected 'Feb' in:\n{text}");
|
||||
}
|
||||
|
||||
// ── Row headers ───────────────────────────────────────────────────────────
|
||||
@ -352,7 +581,7 @@ mod tests {
|
||||
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("Food"), "expected 'Food' in:\n{text}");
|
||||
assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}");
|
||||
}
|
||||
|
||||
@ -361,7 +590,10 @@ mod tests {
|
||||
#[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));
|
||||
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}");
|
||||
}
|
||||
@ -369,13 +601,22 @@ mod tests {
|
||||
#[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));
|
||||
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}");
|
||||
assert!(text.contains("50"), "expected '50' in:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -399,11 +640,20 @@ mod tests {
|
||||
#[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));
|
||||
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}");
|
||||
assert!(
|
||||
text.contains("150"),
|
||||
"expected '150' (total for Jan) in:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page filter bar ───────────────────────────────────────────────────────
|
||||
@ -411,18 +661,25 @@ mod tests {
|
||||
#[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"); }
|
||||
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}");
|
||||
assert!(
|
||||
text.contains("Payer = Bob"),
|
||||
"expected 'Payer = Bob' in:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -431,15 +688,22 @@ mod tests {
|
||||
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("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}");
|
||||
assert!(
|
||||
text.contains("Payer = Alice"),
|
||||
"expected 'Payer = Alice' in:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Formula evaluation ────────────────────────────────────────────────────
|
||||
@ -447,16 +711,24 @@ mod tests {
|
||||
#[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
|
||||
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));
|
||||
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));
|
||||
@ -468,23 +740,38 @@ mod tests {
|
||||
#[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("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);
|
||||
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}");
|
||||
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("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}");
|
||||
assert!(text.contains("Alice"), "expected 'Alice' in:\n{text}");
|
||||
assert!(text.contains("Bob"), "expected 'Bob' in:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -493,10 +780,18 @@ mod tests {
|
||||
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);
|
||||
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")]),
|
||||
@ -509,19 +804,33 @@ mod tests {
|
||||
#[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);
|
||||
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("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}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user