feat(ui): add dynamic column widths for records mode
Implement dynamic column widths for the grid widget when in records mode. In records mode, each column width is computed based on the widest content (pending edit, record value, or header label), with a minimum of 6 characters and maximum of 32. Pivot mode continues to use fixed 10-character column widths. The rendering code has been updated to use the computed column widths and x-offsets for all grid elements: headers, data cells, and totals. Note that the total row is now only displayed in pivot mode, as it is not meaningful in records mode. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
This commit is contained in:
@ -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(),
|
||||
|
||||
154
src/ui/grid.rs
154
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<u16> = 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<u16> = (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<u16> = {
|
||||
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!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
||||
Style::default(),
|
||||
);
|
||||
let mut x = area.x + ROW_HEADER_WIDTH;
|
||||
let mut prev_group: Option<String> = 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!(
|
||||
"{:<width$}",
|
||||
truncate(&label, COL_WIDTH as usize),
|
||||
width = COL_WIDTH as usize
|
||||
),
|
||||
format!("{:<width$}", truncate(&label, cw), width = cw),
|
||||
group_style,
|
||||
);
|
||||
x += COL_WIDTH;
|
||||
}
|
||||
y += 1;
|
||||
}
|
||||
@ -168,8 +234,12 @@ impl<'a> GridWidget<'a> {
|
||||
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
||||
Style::default(),
|
||||
);
|
||||
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 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!("{:─<width$}", "", width = COL_WIDTH as usize),
|
||||
format!("{:─<width$}", "", width = cw),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
);
|
||||
x += COL_WIDTH;
|
||||
}
|
||||
}
|
||||
AxisEntry::DataItem(_) => {
|
||||
@ -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!("{:<width$}", buffer, width = COL_WIDTH as usize),
|
||||
COL_WIDTH as usize,
|
||||
),
|
||||
truncate(&format!("{:<width$}", buffer, width = cw), cw),
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::UNDERLINED),
|
||||
@ -373,8 +427,8 @@ impl<'a> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user