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)
This commit is contained in:
Edward Langley
2026-04-06 15:09:58 -07:00
parent 7bd2296a22
commit ad95bc34a9

View File

@ -11,10 +11,11 @@ use crate::model::Model;
use crate::ui::app::AppMode; use crate::ui::app::AppMode;
use crate::view::{AxisEntry, GridLayout}; use crate::view::{AxisEntry, GridLayout};
const ROW_HEADER_WIDTH: u16 = 16; /// Minimum column width — enough for short numbers/labels + 1 char gap.
const COL_WIDTH: u16 = 10; const MIN_COL_WIDTH: u16 = 5;
const MIN_COL_WIDTH: u16 = 6;
const MAX_COL_WIDTH: u16 = 32; 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. /// Subtle dark-gray background used to highlight the row containing the cursor.
const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237); const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237);
const GROUP_EXPANDED: &str = ""; const GROUP_EXPANDED: &str = "";
@ -70,12 +71,13 @@ impl<'a> GridWidget<'a> {
let n_col_levels = layout.col_cats.len().max(1); let n_col_levels = layout.col_cats.len().max(1);
let n_row_levels = layout.row_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 // ── Adaptive column widths ────────────────────────────────────
// content (pending edit → record value → header label). Otherwise use // Size each column to fit its widest content (header + cell values)
// the fixed COL_WIDTH. Always at least MIN_COL_WIDTH, capped at MAX. // plus 1 char gap. Minimum MIN_COL_WIDTH, capped at MAX_COL_WIDTH.
let col_widths: Vec<u16> = if layout.is_records_mode() { let col_widths: Vec<u16> = {
let n = layout.col_count(); 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 { for ci in 0..n {
let header = layout.col_label(ci); let header = layout.col_label(ci);
let w = header.width() as u16; let w = header.width() as u16;
@ -83,39 +85,44 @@ impl<'a> GridWidget<'a> {
widths[ci] = w; widths[ci] = w;
} }
} }
for ri in 0..layout.row_count() { // Measure cell content
for (ci, wref) in widths.iter_mut().enumerate().take(n) { if layout.is_records_mode() {
let s = self.records_cell_text(&layout, ri, ci); for ri in 0..layout.row_count() {
let w = s.width() as u16; for (ci, wref) in widths.iter_mut().enumerate().take(n) {
if w > *wref { let s = self.records_cell_text(&layout, ri, ci);
*wref = w; 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 widths
.into_iter() .into_iter()
.map(|w| (w + 2).min(MAX_COL_WIDTH)) .map(|w| (w + 1).max(MIN_COL_WIDTH).min(MAX_COL_WIDTH))
.collect() .collect()
} else {
vec![COL_WIDTH; layout.col_count()]
}; };
// Sub-column widths for row header area // ── Adaptive row header widths ───────────────────────────────
let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16; // Measure the widest label at each row-header level.
let sub_widths: Vec<u16> = (0..n_row_levels) let data_row_items: Vec<&Vec<String>> = layout
.map(|d| { .row_items
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() .iter()
.filter_map(|e| { .filter_map(|e| {
if let AxisEntry::DataItem(v) = e { if let AxisEntry::DataItem(v) = e {
@ -125,8 +132,23 @@ impl<'a> GridWidget<'a> {
} }
}) })
.collect(); .collect();
let data_row_items: Vec<&Vec<String>> = layout
.row_items 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).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<String>> = layout
.col_items
.iter() .iter()
.filter_map(|e| { .filter_map(|e| {
if let AxisEntry::DataItem(v) = e { if let AxisEntry::DataItem(v) = e {
@ -143,11 +165,11 @@ impl<'a> GridWidget<'a> {
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })); .any(|e| matches!(e, AxisEntry::GroupHeader { .. }));
// Compute how many columns fit starting from col_offset. // 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 acc = 0u16;
let mut last = col_offset; let mut last = col_offset;
for ci in col_offset..layout.col_count() { 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 { if acc + w > data_area_width {
break; break;
} }
@ -160,16 +182,16 @@ impl<'a> GridWidget<'a> {
let col_x: Vec<u16> = { let col_x: Vec<u16> = {
let mut v = vec![0u16; layout.col_count() + 1]; let mut v = vec![0u16; layout.col_count() + 1];
for ci in 0..layout.col_count() { 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 v
}; };
let col_x_at = |ci: usize| -> u16 { let col_x_at = |ci: usize| -> u16 {
area.x area.x
+ ROW_HEADER_WIDTH + row_header_width
+ col_x[ci].saturating_sub(col_x[col_offset]) + 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 }; 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( buf.set_string(
area.x, area.x,
y, y,
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize), format!("{:<width$}", "", width = row_header_width as usize),
Style::default(), Style::default(),
); );
let mut prev_group: Option<String> = None; let mut prev_group: Option<String> = None;
@ -233,7 +255,7 @@ impl<'a> GridWidget<'a> {
buf.set_string( buf.set_string(
area.x, area.x,
y, y,
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize), format!("{:<width$}", "", width = row_header_width as usize),
Style::default(), Style::default(),
); );
for ci in visible_col_range.clone() { for ci in visible_col_range.clone() {
@ -252,7 +274,17 @@ impl<'a> GridWidget<'a> {
String::new() 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) header_style.add_modifier(Modifier::UNDERLINED)
} else { } else {
header_style header_style
@ -301,8 +333,8 @@ impl<'a> GridWidget<'a> {
y, y,
format!( format!(
"{:<width$}", "{:<width$}",
truncate(&label, ROW_HEADER_WIDTH as usize), truncate(&label, row_header_width as usize),
width = ROW_HEADER_WIDTH as usize width = row_header_width as usize
), ),
group_header_style, group_header_style,
); );
@ -340,9 +372,9 @@ impl<'a> GridWidget<'a> {
if is_sel_row { if is_sel_row {
let row_w = (area.x + area.width).saturating_sub(area.x); let row_w = (area.x + area.width).saturating_sub(area.x);
buf.set_string( buf.set_string(
area.x + ROW_HEADER_WIDTH, area.x + row_header_width,
y, 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), Style::default().bg(ROW_HIGHLIGHT_BG),
); );
} }
@ -407,7 +439,12 @@ impl<'a> GridWidget<'a> {
// "drill to edit". Records mode cells are always // "drill to edit". Records mode cells are always
// directly editable, as are plain pivot cells. // directly editable, as are plain pivot cells.
let is_aggregated = !layout.is_records_mode() 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 { let mut cell_style = if is_selected {
Style::default() Style::default()
.fg(Color::Black) .fg(Color::Black)
@ -479,7 +516,7 @@ impl<'a> GridWidget<'a> {
buf.set_string( buf.set_string(
area.x, area.x,
y, y,
format!("{:<width$}", "Total", width = ROW_HEADER_WIDTH as usize), format!("{:<width$}", "Total", width = row_header_width as usize),
Style::default() Style::default()
.fg(Color::Yellow) .fg(Color::Yellow)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@ -667,6 +704,7 @@ mod tests {
} }
/// Minimal model: Type on Row, Month on Column. /// Minimal model: Type on Row, Month on Column.
/// Every cell has a value so rows/cols survive pruning.
fn two_cat_model() -> Model { fn two_cat_model() -> Model {
let mut m = Model::new("Test"); let mut m = Model::new("Test");
m.add_category("Type").unwrap(); // → Row m.add_category("Type").unwrap(); // → Row
@ -679,6 +717,15 @@ mod tests {
c.add_item("Jan"); c.add_item("Jan");
c.add_item("Feb"); 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 m
} }
@ -738,10 +785,19 @@ mod tests {
#[test] #[test]
fn unset_cells_show_no_value() { 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)); let text = buf_text(&render(&m, 80, 24));
// No digits should appear in the data area if nothing is set // Should not contain large numbers that weren't set
// (Total row shows "0" — exclude that from this check by looking for non-zero)
assert!(!text.contains("100"), "unexpected '100' in:\n{text}"); assert!(!text.contains("100"), "unexpected '100' in:\n{text}");
} }
@ -873,6 +929,15 @@ mod tests {
} }
m.active_view_mut() m.active_view_mut()
.set_axis("Recipient", crate::view::Axis::Row); .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)); let text = buf_text(&render(&m, 80, 24));
// Multi-level row headers: category values shown separately, not joined with / // Multi-level row headers: category values shown separately, not joined with /
@ -936,6 +1001,13 @@ mod tests {
} }
m.active_view_mut() m.active_view_mut()
.set_axis("Year", crate::view::Axis::Column); .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)); let text = buf_text(&render(&m, 80, 24));
// Multi-level column headers: category values shown separately, not joined with / // Multi-level column headers: category values shown separately, not joined with /