fix: use precise column widths for viewport scrolling

Improve grid viewport calculations by using actual column widths instead of
rough estimates. This ensures that the viewport scrolls correctly when the
cursor moves past the visible area.

- Move column width and visible column calculations to public functions in
  `src/ui/grid.rs`.
- Update `App::cmd_context` to use these precise calculations for `visible_cols`
  .
- Add a regression test to verify that `col_offset` scrolls when cursor moves
  past visible columns.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
This commit is contained in:
Edward Langley
2026-04-06 21:21:30 -07:00
parent ebf4a5ea18
commit b5418f2eea
2 changed files with 249 additions and 44 deletions

View File

@ -71,56 +71,23 @@ impl<'a> GridWidget<'a> {
let n_col_levels = layout.col_cats.len().max(1);
let n_row_levels = layout.row_cats.len().max(1);
// ── 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<u16> = {
let mut col_widths = compute_col_widths(self.model, &layout, fmt_comma, fmt_decimals);
// Records mode: also measure cell text widths (needs drill_state)
if layout.is_records_mode() {
let n = layout.col_count();
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;
if w > widths[ci] {
widths[ci] = 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;
}
}
for ri in 0..layout.row_count() {
for (ci, wref) in col_widths.iter_mut().enumerate().take(n) {
let s = self.records_cell_text(&layout, ri, ci);
let w = s.width() as u16;
let needed = (w + 1).max(MIN_COL_WIDTH).min(MAX_COL_WIDTH);
if needed > *wref {
*wref = needed;
}
}
}
// +1 for gap between columns
widths
.into_iter()
.map(|w| (w + 1).max(MIN_COL_WIDTH).min(MAX_COL_WIDTH))
.collect()
};
}
// ── Adaptive row header widths ───────────────────────────────
// Measure the widest label at each row-header level.
let data_row_items: Vec<&Vec<String>> = layout
.row_items
.iter()
@ -588,6 +555,112 @@ impl<'a> Widget for GridWidget<'a> {
}
}
/// 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;
}
}
}
if !layout.is_records_mode() {
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 = 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;
}
}
}
}
// Measure total row (column sums) — totals can be wider than any single cell
if 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).max(MIN_COL_WIDTH).min(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).max(MIN_ROW_HEADER_W).min(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 ci in col_offset..col_widths.len() {
let w = col_widths[ci];
if acc + w > data_area_width {
break;
}
acc += w;
count += 1;
}
count.max(1)
}
fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
match v {
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),