diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 17bdde6..b97a23e 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -11,10 +11,11 @@ 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 MIN_COL_WIDTH: u16 = 6; +/// 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 = "▼"; @@ -70,12 +71,13 @@ impl<'a> GridWidget<'a> { let n_col_levels = layout.col_cats.len().max(1); let n_row_levels = layout.row_cats.len().max(1); - // Per-column widths. In records mode, size each column to its widest - // content (pending edit → record value → header label). Otherwise use - // the fixed COL_WIDTH. Always at least MIN_COL_WIDTH, capped at MAX. - let col_widths: Vec = if layout.is_records_mode() { + // ── Adaptive column widths ──────────────────────────────────── + // Size each column to fit its widest content (header + cell values) + // plus 1 char gap. Minimum MIN_COL_WIDTH, capped at MAX_COL_WIDTH. + let col_widths: Vec = { let n = layout.col_count(); - let mut widths = vec![MIN_COL_WIDTH; n]; + let mut widths = vec![0u16; n]; + // Measure column header labels for ci in 0..n { let header = layout.col_label(ci); let w = header.width() as u16; @@ -83,39 +85,44 @@ impl<'a> GridWidget<'a> { widths[ci] = w; } } - for ri in 0..layout.row_count() { - for (ci, wref) in widths.iter_mut().enumerate().take(n) { - let s = self.records_cell_text(&layout, ri, ci); - let w = s.width() as u16; - if w > *wref { - *wref = w; + // Measure cell content + if layout.is_records_mode() { + for ri in 0..layout.row_count() { + for (ci, wref) in widths.iter_mut().enumerate().take(n) { + let s = self.records_cell_text(&layout, ri, ci); + let w = s.width() as u16; + if w > *wref { + *wref = w; + } + } + } + } else { + // Pivot mode: measure formatted cell values + for ri in 0..layout.row_count() { + for (ci, wref) in widths.iter_mut().enumerate().take(n) { + if let Some(key) = layout.cell_key(ri, ci) { + let value = + self.model.evaluate_aggregated(&key, &layout.none_cats); + let s = format_value(value.as_ref(), fmt_comma, fmt_decimals); + let w = s.width() as u16; + if w > *wref { + *wref = w; + } + } } } } - // Add 2 cells of right-padding; cap at MAX_COL_WIDTH. + // +1 for gap between columns widths .into_iter() - .map(|w| (w + 2).min(MAX_COL_WIDTH)) + .map(|w| (w + 1).max(MIN_COL_WIDTH).min(MAX_COL_WIDTH)) .collect() - } else { - vec![COL_WIDTH; layout.col_count()] }; - // Sub-column widths for row header area - let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16; - let sub_widths: Vec = (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> = layout - .col_items + // ── Adaptive row header widths ─────────────────────────────── + // Measure the widest label at each row-header level. + let data_row_items: Vec<&Vec> = layout + .row_items .iter() .filter_map(|e| { if let AxisEntry::DataItem(v) = e { @@ -125,8 +132,23 @@ impl<'a> GridWidget<'a> { } }) .collect(); - let data_row_items: Vec<&Vec> = layout - .row_items + + 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).max(MIN_ROW_HEADER_W).min(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 { @@ -143,11 +165,11 @@ impl<'a> GridWidget<'a> { .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 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(&COL_WIDTH); + let w = *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH); if acc + w > data_area_width { break; } @@ -160,16 +182,16 @@ impl<'a> GridWidget<'a> { 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(&COL_WIDTH); + 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 + + 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(&COL_WIDTH) }; + 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 }; @@ -187,7 +209,7 @@ impl<'a> GridWidget<'a> { buf.set_string( area.x, y, - format!("{: = None; @@ -233,7 +255,7 @@ impl<'a> GridWidget<'a> { buf.set_string( area.x, y, - format!("{: GridWidget<'a> { String::new() } }; - let styled = if ci == sel_col { + // 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 @@ -301,8 +333,8 @@ impl<'a> GridWidget<'a> { y, format!( "{: GridWidget<'a> { if is_sel_row { let row_w = (area.x + area.width).saturating_sub(area.x); buf.set_string( - area.x + ROW_HEADER_WIDTH, + area.x + row_header_width, y, - " ".repeat(row_w.saturating_sub(ROW_HEADER_WIDTH) as usize), + " ".repeat(row_w.saturating_sub(row_header_width) as usize), Style::default().bg(ROW_HIGHLIGHT_BG), ); } @@ -407,7 +439,12 @@ impl<'a> GridWidget<'a> { // "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.is_empty(); + && 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) @@ -479,7 +516,7 @@ impl<'a> GridWidget<'a> { buf.set_string( area.x, y, - format!("{: Model { let mut m = Model::new("Test"); m.add_category("Type").unwrap(); // → Row @@ -679,6 +717,15 @@ mod tests { 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 } @@ -738,10 +785,19 @@ mod tests { #[test] fn unset_cells_show_no_value() { - let m = two_cat_model(); + // 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)); - // 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) + // Should not contain large numbers that weren't set assert!(!text.contains("100"), "unexpected '100' in:\n{text}"); } @@ -873,6 +929,15 @@ mod tests { } 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 / @@ -936,6 +1001,13 @@ mod tests { } 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 /