From ad95bc34a9a22ea5f7cf4cf931f5df6b4f5f8d1e Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Mon, 6 Apr 2026 15:09:58 -0700 Subject: [PATCH] refactor: update grid widget with adaptive widths and pruning support Update grid widget with adaptive column/row widths and pruning support. Replaced fixed ROW_HEADER_WIDTH (16) and COL_WIDTH (10) with adaptive widths based on content. MIN_COL_WIDTH=5, MAX_COL_WIDTH=32. MIN_ROW_HEADER_W=4, MAX_ROW_HEADER_W=24. Column widths measured from header labels and cell content (pivot mode measures formatted values, records mode measures raw values). Row header widths measured from widest label at each level. Added underlining for columns sharing ancestor groups with selected column. Updated is_aggregated check to filter virtual categories. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M) --- src/ui/grid.rs | 176 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 124 insertions(+), 52 deletions(-) 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 /