use ratatui::{ buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, widgets::{Block, Borders, Widget}, }; use unicode_width::UnicodeWidthStr; use crate::model::Model; use crate::ui::app::AppMode; use crate::view::{AxisEntry, GridLayout}; /// Minimum column width — enough for short numbers/labels + 1 char gap. const MIN_COL_WIDTH: u16 = 5; const MAX_COL_WIDTH: u16 = 32; const MIN_ROW_HEADER_W: u16 = 4; const MAX_ROW_HEADER_W: u16 = 24; /// Subtle dark-gray background used to highlight the row containing the cursor. const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237); const GROUP_EXPANDED: &str = "▼"; const GROUP_COLLAPSED: &str = "▶"; pub struct GridWidget<'a> { pub model: &'a Model, pub layout: &'a GridLayout, pub mode: &'a AppMode, pub search_query: &'a str, pub buffers: &'a std::collections::HashMap, pub drill_state: Option<&'a crate::ui::app::DrillState>, } impl<'a> GridWidget<'a> { pub fn new( model: &'a Model, layout: &'a GridLayout, mode: &'a AppMode, search_query: &'a str, buffers: &'a std::collections::HashMap, drill_state: Option<&'a crate::ui::app::DrillState>, ) -> Self { Self { model, layout, mode, search_query, buffers, drill_state, } } fn render_grid(&self, area: Rect, buf: &mut Buffer) { let view = self.model.active_view(); let layout = self.layout; 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); let col_widths = compute_col_widths(self.model, layout, fmt_comma, fmt_decimals); // ── Adaptive row header widths ─────────────────────────────── let data_row_items: Vec<&Vec> = layout .row_items .iter() .filter_map(|e| { if let AxisEntry::DataItem(v) = e { Some(v) } else { None } }) .collect(); let sub_widths: Vec = (0..n_row_levels) .map(|d| { let max_label = data_row_items .iter() .filter_map(|v| v.get(d)) .map(|s| s.width() as u16) .max() .unwrap_or(0); (max_label + 1).clamp(MIN_ROW_HEADER_W, MAX_ROW_HEADER_W) }) .collect(); let row_header_width: u16 = sub_widths.iter().sum(); // Flat list of data-only column tuples for repeat-suppression in headers let data_col_items: Vec<&Vec> = layout .col_items .iter() .filter_map(|e| { if let AxisEntry::DataItem(v) = e { Some(v) } else { None } }) .collect(); let has_col_groups = layout .col_items .iter() .any(|e| matches!(e, AxisEntry::GroupHeader { .. })); // Compute how many columns fit starting from col_offset. let data_area_width = area.width.saturating_sub(row_header_width); let mut acc = 0u16; let mut last = col_offset; for ci in col_offset..layout.col_count() { let w = *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH); if acc + w > data_area_width { break; } acc += w; last = ci + 1; } let visible_col_range = col_offset..last.max(col_offset + 1).min(layout.col_count()); // x offset (relative to the data area start) for each column index. let col_x: Vec = { let mut v = vec![0u16; layout.col_count() + 1]; for ci in 0..layout.col_count() { v[ci + 1] = v[ci] + *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH); } v }; let col_x_at = |ci: usize| -> u16 { area.x + row_header_width + col_x[ci].saturating_sub(col_x[col_offset]) }; let col_w_at = |ci: usize| -> u16 { *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH) }; 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!("{: = None; for ci in visible_col_range.clone() { let x = col_x_at(ci); if x >= area.x + area.width { break; } let cw = col_w_at(ci) as usize; let col_group = layout.col_group_for(ci); let group_name = col_group.as_ref().map(|(_, g)| g.clone()); let label = if group_name != prev_group { match &col_group { Some((cat, g)) => { let indicator = if view.is_group_collapsed(cat, g) { GROUP_COLLAPSED } else { GROUP_EXPANDED }; format!("{indicator} {g}") } None => String::new(), } } else { String::new() }; prev_group = group_name; buf.set_string( x, y, format!( "{:= area.x + area.width { break; } let cw = col_w_at(ci) as usize; 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() } }; // Underline columns that share the same ancestor group as // sel_col through level d. At the bottom level this matches // only sel_col; at higher levels it spans all sub-columns. let in_sel_group = if layout.col_cats.is_empty() { ci == sel_col } else if sel_col < data_col_items.len() && ci < data_col_items.len() { data_col_items[ci][..=d] == data_col_items[sel_col][..=d] } else { false }; let styled = if in_sel_group { header_style.add_modifier(Modifier::UNDERLINED) } else { header_style }; buf.set_string( x, y, format!( "{:>width$}", truncate(&label, cw.saturating_sub(1)), width = cw ), styled, ); } 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!( "{:= area.x + area.width { break; } let cw = col_w_at(ci) as usize; buf.set_string( x, y, format!("{:─ { let ri = data_row_idx; data_row_idx += 1; let is_sel_row = ri == sel_row; let row_style = if is_sel_row { Style::default() .fg(Color::Cyan) .bg(ROW_HIGHLIGHT_BG) .add_modifier(Modifier::BOLD) } else { Style::default() }; // Paint row-highlight background across the entire row // (data area + any trailing space) so gaps between columns // and the margin after the last column share the highlight. if is_sel_row { let row_w = (area.x + area.width).saturating_sub(area.x); buf.set_string( area.x + row_header_width, y, " ".repeat(row_w.saturating_sub(row_header_width) as usize), Style::default().bg(ROW_HIGHLIGHT_BG), ); } // 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!("{:= area.x + area.width { break; } let cw = col_w_at(ci) as usize; // Check pending drill edits first, then use display_text let cell_str = if let Some(ds) = self.drill_state { let col_name = layout.col_label(ci); ds.pending_edits .get(&(ri, col_name)) .cloned() .unwrap_or_else(|| { layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals) }) } else { layout.display_text(self.model, ri, ci, 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()); // Aggregated cells (pivot view with hidden dims) are // not directly editable — shown in italic to signal // "drill to edit". Records mode cells are always // directly editable, as are plain pivot cells. let is_aggregated = !layout.is_records_mode() && layout.none_cats.iter().any(|c| { self.model .category(c) .map(|cat| cat.kind.is_regular()) .unwrap_or(false) }); let mut 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 is_sel_row { let fg = if cell_str.is_empty() { Color::DarkGray } else { Color::White }; Style::default().fg(fg).bg(ROW_HIGHLIGHT_BG) } else if cell_str.is_empty() { Style::default().fg(Color::DarkGray) } else { Style::default() }; if is_aggregated { cell_style = cell_style.add_modifier(Modifier::ITALIC); } buf.set_string( x, y, format!( "{:>width$}", truncate(&cell_str, cw.saturating_sub(1)), width = cw ), cell_style, ); } // Edit indicator if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row { { let buffer = self.buffers.get("edit").map(|s| s.as_str()).unwrap_or(""); let edit_x = col_x_at(sel_col); let cw = col_w_at(sel_col) as usize; 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 cw = col_w_at(ci) as usize; let total: f64 = (0..layout.row_count()) .filter_map(|ri| layout.cell_key(ri, ci)) .map(|key| self.model.evaluate_aggregated_f64(&key, &layout.none_cats)) .sum(); let total_str = format_f64(total, fmt_comma, fmt_decimals); buf.set_string( x, y, format!( "{:>width$}", truncate(&total_str, cw.saturating_sub(1)), width = cw ), Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ); } } } } } 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 !self.layout.page_coords.is_empty() && inner.height > 0 { let page_info: Vec = self .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); } } } /// Compute adaptive column widths for pivot mode (header labels + cell values). /// Header widths use the widest *individual* level label (not the joined /// multi-level string), matching how the grid renderer draws each level on /// its own row with repeat-suppression. pub fn compute_col_widths( model: &Model, layout: &GridLayout, fmt_comma: bool, fmt_decimals: u8, ) -> Vec { let n = layout.col_count(); let mut widths = vec![0u16; n]; // Measure individual header level labels let data_col_items: Vec<&Vec> = layout .col_items .iter() .filter_map(|e| { if let AxisEntry::DataItem(v) = e { Some(v) } else { None } }) .collect(); for (ci, wref) in widths.iter_mut().enumerate().take(n) { if let Some(levels) = data_col_items.get(ci) { let max_level_w = levels.iter().map(|s| s.width() as u16).max().unwrap_or(0); if max_level_w > *wref { *wref = max_level_w; } } } // Measure cell content widths (works for both pivot and records modes) for ri in 0..layout.row_count() { for (ci, wref) in widths.iter_mut().enumerate().take(n) { let s = layout.display_text(model, ri, ci, fmt_comma, fmt_decimals); let w = s.width() as u16; if w > *wref { *wref = w; } } } // Measure total row (column sums) — pivot mode only if !layout.is_records_mode() && layout.row_count() > 0 { for (ci, wref) in widths.iter_mut().enumerate().take(n) { let total: f64 = (0..layout.row_count()) .filter_map(|ri| layout.cell_key(ri, ci)) .map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats)) .sum(); let s = format_f64(total, fmt_comma, fmt_decimals); let w = s.width() as u16; if w > *wref { *wref = w; } } } widths .into_iter() .map(|w| (w + 1).clamp(MIN_COL_WIDTH, MAX_COL_WIDTH)) .collect() } /// Compute the total row header width from the layout's row items. pub fn compute_row_header_width(layout: &GridLayout) -> u16 { let n_row_levels = layout.row_cats.len().max(1); let data_row_items: Vec<&Vec> = layout .row_items .iter() .filter_map(|e| { if let AxisEntry::DataItem(v) = e { Some(v) } else { None } }) .collect(); let sub_widths: Vec = (0..n_row_levels) .map(|d| { let max_label = data_row_items .iter() .filter_map(|v| v.get(d)) .map(|s| s.width() as u16) .max() .unwrap_or(0); (max_label + 1).clamp(MIN_ROW_HEADER_W, MAX_ROW_HEADER_W) }) .collect(); sub_widths.iter().sum() } /// Count how many columns fit starting from `col_offset` given the available width. pub fn compute_visible_cols( col_widths: &[u16], row_header_width: u16, term_width: u16, col_offset: usize, ) -> usize { // Account for grid border (2 chars) let data_area_width = term_width .saturating_sub(2) .saturating_sub(row_header_width); let mut acc = 0u16; let mut count = 0usize; for w in &col_widths[col_offset..] { let w = *w; if acc + w > data_area_width { break; } acc += w; count += 1; } count.max(1) } // Re-export shared formatting functions pub use crate::format::{format_f64, parse_number_format}; 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; use crate::view::GridLayout; // ── 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); let bufs = std::collections::HashMap::new(); let layout = GridLayout::new(model, model.active_view()); GridWidget::new(model, &layout, &AppMode::Normal, "", &bufs, None).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. /// Every cell has a value so rows/cols survive pruning. 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"); } // Fill every cell so nothing is pruned as empty. for t in ["Food", "Clothing"] { for mo in ["Jan", "Feb"] { m.set_cell(coord(&[("Type", t), ("Month", mo)]), CellValue::Number(1.0)); } } 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() { // Build a model without the two_cat_model helper (which fills every cell). let mut m = Model::new("Test"); m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); m.category_mut("Type").unwrap().add_item("Food"); m.category_mut("Month").unwrap().add_item("Jan"); // Set one cell so the row/col isn't pruned m.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(1.0), ); let text = buf_text(&render(&m, 80, 24)); // Should not contain large numbers that weren't set 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); // Populate cells so rows/cols survive pruning for t in ["Food", "Clothing"] { for r in ["Alice", "Bob"] { m.set_cell( coord(&[("Type", t), ("Month", "Jan"), ("Recipient", r)]), CellValue::Number(1.0), ); } } 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); // Populate cells so cols survive pruning for y in ["2024", "2025"] { m.set_cell( coord(&[("Type", "Food"), ("Month", "Jan"), ("Year", y)]), CellValue::Number(1.0), ); } 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}"); } }