diff --git a/src/command/cmd.rs b/src/command/cmd.rs index 0d3f01a..3d4a9fa 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -2647,6 +2647,7 @@ mod tests { view_back_stack: Vec::new(), view_forward_stack: Vec::new(), records_col: None, + records_value: None, cell_key: layout.cell_key(sr, sc), row_count: layout.row_count(), col_count: layout.col_count(), diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 72d585e..469c83a 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -13,6 +13,8 @@ use crate::view::{AxisEntry, GridLayout}; const ROW_HEADER_WIDTH: u16 = 16; const COL_WIDTH: u16 = 10; +const MIN_COL_WIDTH: u16 = 6; +const MAX_COL_WIDTH: u16 = 32; const GROUP_EXPANDED: &str = "▼"; const GROUP_COLLAPSED: &str = "▶"; @@ -41,6 +43,18 @@ impl<'a> GridWidget<'a> { } } + /// In records mode, get the display text for (row, col): pending edit if + /// staged, otherwise the underlying record's value for that column. + fn records_cell_text(&self, layout: &GridLayout, row: usize, col: usize) -> String { + let col_name = layout.col_label(col); + let pending = self + .drill_state + .and_then(|s| s.pending_edits.get(&(row, col_name.clone())).cloned()); + pending + .or_else(|| layout.records_display(row, col)) + .unwrap_or_default() + } + fn render_grid(&self, area: Rect, buf: &mut Buffer) { let view = self.model.active_view(); @@ -54,6 +68,37 @@ 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() { + let n = layout.col_count(); + let mut widths = vec![MIN_COL_WIDTH; n]; + for ci in 0..n { + let header = layout.col_label(ci); + let w = header.width() as u16; + if w > widths[ci] { + 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; + } + } + } + // Add 2 cells of right-padding; cap at MAX_COL_WIDTH. + widths + .into_iter() + .map(|w| (w + 2).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) @@ -95,9 +140,34 @@ impl<'a> GridWidget<'a> { .iter() .any(|e| matches!(e, AxisEntry::GroupHeader { .. })); - let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize; - let visible_col_range = - col_offset..(col_offset + available_cols.max(1)).min(layout.col_count()); + // 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(&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 = { + 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 + }; + 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(&COL_WIDTH) }; let _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 }; @@ -118,12 +188,13 @@ impl<'a> GridWidget<'a> { format!("{: = 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 { @@ -145,14 +216,9 @@ impl<'a> GridWidget<'a> { buf.set_string( x, y, - format!( - "{: GridWidget<'a> { format!("{:= 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 { @@ -188,17 +258,9 @@ impl<'a> GridWidget<'a> { buf.set_string( x, y, - format!( - "{:>width$}", - truncate(&label, COL_WIDTH as usize), - width = COL_WIDTH as usize - ), + format!("{:>width$}", truncate(&label, cw), width = cw), styled, ); - x += COL_WIDTH; - if x >= area.x + area.width { - break; - } } y += 1; } @@ -242,15 +304,18 @@ impl<'a> GridWidget<'a> { ), group_header_style, ); - let mut x = area.x + ROW_HEADER_WIDTH; - while x < area.x + area.width { + 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!("{:─ { @@ -289,14 +354,15 @@ impl<'a> GridWidget<'a> { hx += sub_widths[d]; } - let mut x = area.x + ROW_HEADER_WIDTH; 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 (cell_str, value) = if layout.is_records_mode() { - let s = layout.records_display(ri, ci).unwrap_or_default(); + let s = self.records_cell_text(&layout, ri, ci); // In records mode the value is a string, not aggregated let v = if !s.is_empty() { Some(crate::model::cell::CellValue::Text(s.clone())) @@ -307,10 +373,7 @@ impl<'a> GridWidget<'a> { } else { let key = match layout.cell_key(ri, ci) { Some(k) => k, - None => { - x += COL_WIDTH; - continue; - } + None => continue, }; let value = self.model.evaluate_aggregated(&key, &layout.none_cats); let s = format_value(value.as_ref(), fmt_comma, fmt_decimals); @@ -338,30 +401,21 @@ impl<'a> GridWidget<'a> { buf.set_string( x, y, - format!( - "{:>width$}", - truncate(&cell_str, COL_WIDTH as usize), - width = COL_WIDTH as usize - ), + format!("{:>width$}", truncate(&cell_str, cw), width = cw), cell_style, ); - x += COL_WIDTH; } // 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 = area.x - + ROW_HEADER_WIDTH - + (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH; + 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!("{: GridWidget<'a> { y += 1; } - // Total row - if layout.row_count() > 0 && layout.col_count() > 0 { + // 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, @@ -394,11 +448,12 @@ impl<'a> GridWidget<'a> { .add_modifier(Modifier::BOLD), ); - let mut x = area.x + ROW_HEADER_WIDTH; 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)) @@ -407,16 +462,11 @@ impl<'a> GridWidget<'a> { buf.set_string( x, y, - format!( - "{:>width$}", - truncate(&total_str, COL_WIDTH as usize), - width = COL_WIDTH as usize - ), + format!("{:>width$}", truncate(&total_str, cw), width = cw), Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ); - x += COL_WIDTH; } } }