1037 lines
38 KiB
Rust
1037 lines
38 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::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<String, String>,
|
|
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<String, String>,
|
|
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<String>> = layout
|
|
.row_items
|
|
.iter()
|
|
.filter_map(|e| {
|
|
if let AxisEntry::DataItem(v) = e {
|
|
Some(v)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let sub_widths: Vec<u16> = (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<String>> = 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<u16> = {
|
|
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!("{:<width$}", "", width = row_header_width as usize),
|
|
Style::default(),
|
|
);
|
|
let mut prev_group: Option<String> = 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!(
|
|
"{:<width$}",
|
|
truncate(&label, cw.saturating_sub(1)),
|
|
width = cw
|
|
),
|
|
group_style,
|
|
);
|
|
}
|
|
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(),
|
|
);
|
|
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 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!(
|
|
"{:<width$}",
|
|
truncate(&label, row_header_width as usize),
|
|
width = row_header_width as usize
|
|
),
|
|
group_header_style,
|
|
);
|
|
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;
|
|
buf.set_string(
|
|
x,
|
|
y,
|
|
format!("{:─<width$}", "", width = cw),
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
}
|
|
}
|
|
AxisEntry::DataItem(_) => {
|
|
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!("{:<width$}", truncate(&label, sw), width = sw),
|
|
row_style,
|
|
);
|
|
hx += sub_widths[d];
|
|
}
|
|
|
|
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;
|
|
|
|
// 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!("{:<width$}", buffer, width = cw), cw),
|
|
Style::default()
|
|
.fg(Color::Green)
|
|
.add_modifier(Modifier::UNDERLINED),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
y += 1;
|
|
}
|
|
|
|
// Total row — numeric aggregation, only meaningful in pivot mode.
|
|
if !layout.is_records_mode() && 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),
|
|
);
|
|
|
|
for ci in visible_col_range {
|
|
let x = col_x_at(ci);
|
|
if x >= 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<String> = 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<u16> {
|
|
let n = layout.col_count();
|
|
let mut widths = vec![0u16; n];
|
|
// Measure individual header level labels
|
|
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();
|
|
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<String>> = layout
|
|
.row_items
|
|
.iter()
|
|
.filter_map(|e| {
|
|
if let AxisEntry::DataItem(v) = e {
|
|
Some(v)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
let sub_widths: Vec<u16> = (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::<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.
|
|
/// 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}");
|
|
}
|
|
}
|