From e09ddf71a72708ce0d67366ac5442c380d545d99 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 7 Apr 2026 09:16:25 -0700 Subject: [PATCH] refactor!(ui): use GridLayout for layout and display Rebuilt App to hold a GridLayout and recompute it on state changes. Updated cmd_context to use layout and display_value. Replaced manual width calculations with compute_col_widths and compute_visible_cols. Updated GridWidget to use layout and drill_state. Added Panel::mode helper and updated UI titles. Fixed display logic for records mode using layout.display_text. Co-Authored-By: fiddlerwoaroof/git-smart-commit (bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF) --- src/persistence/mod.rs | 12 +- src/ui/app.rs | 275 ++++++++++++++++++++++++++++++++++----- src/ui/category_panel.rs | 2 +- src/ui/effect.rs | 22 ++-- src/ui/grid.rs | 240 +++++++++++++++++----------------- src/ui/view_panel.rs | 2 +- 6 files changed, 372 insertions(+), 181 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 2912448..4052e29 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -458,17 +458,7 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> { out.push(','); } let row_values: Vec = (0..layout.col_count()) - .map(|ci| { - if layout.is_records_mode() { - layout.records_display(ri, ci).unwrap_or_default() - } else { - layout - .cell_key(ri, ci) - .and_then(|key| model.evaluate_aggregated(&key, &layout.none_cats)) - .map(|v| v.to_string()) - .unwrap_or_default() - } - }) + .map(|ci| layout.display_text(model, ri, ci, false, 0)) .collect(); out.push_str(&row_values.join(",")); out.push('\n'); diff --git a/src/ui/app.rs b/src/ui/app.rs index 664964b..c499809 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5,23 +5,25 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; +use std::rc::Rc; + use crate::command::cmd::CmdContext; use crate::command::keymap::{Keymap, KeymapSet}; use crate::import::wizard::ImportWizard; use crate::model::cell::CellValue; use crate::model::Model; use crate::persistence; +use crate::ui::grid::{ + compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format, +}; use crate::view::GridLayout; /// Drill-down state: frozen record snapshot + pending edits that have not /// yet been applied to the model. #[derive(Debug, Clone, Default)] pub struct DrillState { - /// Frozen snapshot of records shown in the drill view. - pub records: Vec<( - crate::model::cell::CellKey, - crate::model::cell::CellValue, - )>, + /// Frozen snapshot of records shown in the drill view (Rc for cheap cloning). + pub records: Rc>, /// Pending edits keyed by (record_idx, column_name) → new string value. /// column_name is either "Value" or a category name. pub pending_edits: std::collections::HashMap<(usize, String), String>, @@ -100,11 +102,18 @@ pub struct App { pub buffers: HashMap, /// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.) pub transient_keymap: Option>, + /// Current grid layout, derived from model + view + drill_state. + /// Rebuilt via `rebuild_layout()` after state changes. + pub layout: GridLayout, keymap_set: KeymapSet, } impl App { pub fn new(model: Model, file_path: Option) -> Self { + let layout = { + let view = model.active_view(); + GridLayout::with_frozen_records(&model, view, None) + }; Self { model, file_path, @@ -131,17 +140,26 @@ impl App { expanded_cats: std::collections::HashSet::new(), buffers: HashMap::new(), transient_keymap: None, + layout, keymap_set: KeymapSet::default_keymaps(), } } + /// Rebuild the grid layout from current model, view, and drill state. + /// Note: `with_frozen_records` already handles pruning internally. + pub fn rebuild_layout(&mut self) { + let view = self.model.active_view(); + let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records)); + self.layout = GridLayout::with_frozen_records(&self.model, view, frozen); + } + pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> { let view = self.model.active_view(); - let frozen_records = self.drill_state.as_ref().map(|s| s.records.clone()); - let layout = GridLayout::with_frozen_records(&self.model, view, frozen_records); + let layout = &self.layout; let (sel_row, sel_col) = view.selected; CmdContext { model: &self.model, + layout, mode: &self.mode, selected: view.selected, row_offset: view.row_offset, @@ -158,34 +176,39 @@ impl App { cat_panel_cursor: self.cat_panel_cursor, view_panel_cursor: self.view_panel_cursor, tile_cat_idx: self.tile_cat_idx, - cell_key: layout.cell_key(sel_row, sel_col), - row_count: layout.row_count(), - col_count: layout.col_count(), - none_cats: layout.none_cats.clone(), - view_back_stack: self.view_back_stack.clone(), - view_forward_stack: self.view_forward_stack.clone(), - records_col: if layout.is_records_mode() { - Some(layout.col_label(sel_col)) - } else { - None + view_back_stack: &self.view_back_stack, + view_forward_stack: &self.view_forward_stack, + display_value: { + let key = layout.cell_key(sel_row, sel_col); + if let Some(k) = &key { + if let Some((idx, dim)) = crate::view::synthetic_record_info(k) { + self.drill_state + .as_ref() + .and_then(|s| s.pending_edits.get(&(idx, dim)).cloned()) + .or_else(|| layout.resolve_display(k)) + .unwrap_or_default() + } else { + self.model + .get_cell(k) + .map(|v| v.to_string()) + .unwrap_or_default() + } + } else { + String::new() + } }, - records_value: if layout.is_records_mode() { - // Check pending edits first, then fall back to original - let col_name = layout.col_label(sel_col); - let pending = self.drill_state.as_ref().and_then(|s| { - s.pending_edits.get(&(sel_row, col_name.clone())).cloned() - }); - pending.or_else(|| layout.records_display(sel_row, sel_col)) - } else { - None - }, - // Approximate visible rows/cols from terminal size. - // Chrome: title(1) + border(2) + col_headers(n_col_levels) + separator(1) - // + tile_bar(1) + status_bar(1) = ~8 rows of chrome. visible_rows: (self.term_height as usize).saturating_sub(8), - // Visible cols depends on column widths — use a rough estimate. - // The grid renderer does the precise calculation. - visible_cols: ((self.term_width as usize).saturating_sub(30) / 12).max(1), + visible_cols: { + let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format); + let col_widths = compute_col_widths(&self.model, layout, fmt_comma, fmt_decimals); + let row_header_width = compute_row_header_width(layout); + compute_visible_cols( + &col_widths, + row_header_width, + self.term_width, + view.col_offset, + ) + }, expanded_cats: &self.expanded_cats, key_code: key, } @@ -195,6 +218,7 @@ impl App { for effect in effects { effect.apply(self); } + self.rebuild_layout(); } /// True when the model has no categories yet (show welcome screen) @@ -203,6 +227,8 @@ impl App { } pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> { + self.rebuild_layout(); + // Transient keymap (prefix key sequence) takes priority if let Some(transient) = self.transient_keymap.take() { let effects = { @@ -247,7 +273,7 @@ impl App { pub fn hint_text(&self) -> &'static str { match &self.mode { AppMode::Normal => "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd", - AppMode::Editing { .. } => "Enter:commit Esc:cancel", + AppMode::Editing { .. } => "Enter:commit Tab:commit+right Esc:cancel", AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back", AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression", AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back", @@ -371,6 +397,187 @@ mod tests { assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("q")); } + #[test] + fn col_offset_scrolls_when_cursor_moves_past_visible_columns() { + use crate::model::cell::{CellKey, CellValue}; + // Create a model with 8 wide columns. Column item names are 30 chars + // each → column widths ~31 chars. With term_width=80, row header ~4, + // data area ~76 → only ~2 columns actually fit. But the rough estimate + // (80−30)/12 = 4 over-counts, so viewport_effects never scrolls. + let mut m = Model::new("T"); + m.add_category("Row").unwrap(); + m.add_category("Col").unwrap(); + m.category_mut("Row").unwrap().add_item("R1"); + for i in 0..8 { + let name = format!("VeryLongColumnItemName_{i:03}"); + m.category_mut("Col").unwrap().add_item(&name); + } + // Populate a value so the model isn't empty + let key = CellKey::new(vec![ + ("Row".to_string(), "R1".to_string()), + ("Col".to_string(), "VeryLongColumnItemName_000".to_string()), + ]); + m.set_cell(key, CellValue::Number(1.0)); + + let mut app = App::new(m, None); + app.term_width = 80; + + // Press 'l' (right) 3 times to move cursor to column 3. + // Only ~2 columns fit in 76 chars of data area (each col ~26 chars wide), + // so column 3 is well off-screen. The buggy estimate (80−30)/12 = 4 + // thinks 4 columns fit, so it won't scroll until col 4. + for _ in 0..3 { + app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)) + .unwrap(); + } + + assert_eq!( + app.model.active_view().selected.1, + 3, + "cursor should be at column 3" + ); + assert!( + app.model.active_view().col_offset > 0, + "col_offset should scroll when cursor moves past visible area (only ~2 cols fit \ + in 80-char terminal with 26-char-wide columns), but col_offset is {}", + app.model.active_view().col_offset + ); + } + + #[test] + fn home_jumps_to_first_col() { + let mut app = two_col_model(); + app.model.active_view_mut().selected = (1, 1); + app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)) + .unwrap(); + assert_eq!(app.model.active_view().selected, (1, 0)); + } + + #[test] + fn end_jumps_to_last_col() { + let mut app = two_col_model(); + app.model.active_view_mut().selected = (1, 0); + app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)) + .unwrap(); + assert_eq!(app.model.active_view().selected, (1, 1)); + } + + #[test] + fn page_down_scrolls_by_three_quarters_visible() { + let mut app = two_col_model(); + // Add enough rows + for i in 0..30 { + app.model + .category_mut("Row") + .unwrap() + .add_item(&format!("R{i}")); + } + app.term_height = 28; // ~20 visible rows → delta = 15 + app.model.active_view_mut().selected = (0, 0); + app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)) + .unwrap(); + assert_eq!(app.model.active_view().selected.1, 0, "column preserved"); + assert!( + app.model.active_view().selected.0 > 0, + "row should advance on PageDown" + ); + // 3/4 of ~20 = 15 + assert_eq!(app.model.active_view().selected.0, 15); + } + + #[test] + fn page_up_scrolls_backward() { + let mut app = two_col_model(); + for i in 0..30 { + app.model + .category_mut("Row") + .unwrap() + .add_item(&format!("R{i}")); + } + app.term_height = 28; + app.model.active_view_mut().selected = (20, 0); + app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)) + .unwrap(); + assert_eq!(app.model.active_view().selected.0, 5); + } + + #[test] + fn jump_last_row_scrolls_with_small_terminal() { + let mut app = two_col_model(); + // Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12. + for i in 0..10 { + app.model.category_mut("Row").unwrap().add_item(&format!("R{i}")); + } + app.term_height = 13; // ~5 visible rows + app.model.active_view_mut().selected = (0, 0); + // G jumps to last row (row 12) + app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)) + .unwrap(); + let last = app.model.active_view().selected.0; + assert_eq!(last, 12, "should be at last row"); + // With only ~5 visible rows and 13 rows, offset should scroll. + // Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll. + let offset = app.model.active_view().row_offset; + assert!( + offset > 0, + "row_offset should scroll when last row is beyond visible area, but is {offset}" + ); + } + + #[test] + fn ctrl_d_scrolls_viewport_with_small_terminal() { + let mut app = two_col_model(); + for i in 0..30 { + app.model + .category_mut("Row") + .unwrap() + .add_item(&format!("R{i}")); + } + app.term_height = 13; // ~5 visible rows + app.model.active_view_mut().selected = (0, 0); + // Ctrl+d scrolls by 5 rows + app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(app.model.active_view().selected.0, 5); + // Press Ctrl+d again — now at row 10 with only 5 visible rows, + // row_offset should have scrolled (not stay at 0 due to hardcoded 20) + app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(app.model.active_view().selected.0, 10); + assert!( + app.model.active_view().row_offset > 0, + "row_offset should scroll with small terminal, but is {}", + app.model.active_view().row_offset + ); + } + + #[test] + fn tab_in_edit_mode_commits_and_moves_right() { + let mut app = two_col_model(); + app.model.active_view_mut().selected = (0, 0); + // Enter edit mode + app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) + .unwrap(); + assert!(matches!(app.mode, AppMode::Editing { .. })); + // Type a digit + app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE)) + .unwrap(); + // Press Tab — should commit, move right, re-enter edit mode + app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) + .unwrap(); + // Should be in edit mode on column 1 + assert!( + matches!(app.mode, AppMode::Editing { .. }), + "should be in edit mode after Tab, but mode is {:?}", + app.mode + ); + assert_eq!( + app.model.active_view().selected.1, + 1, + "should have moved to column 1" + ); + } + #[test] fn command_mode_buffer_cleared_on_reentry() { use crossterm::event::KeyEvent; diff --git a/src/ui/category_panel.rs b/src/ui/category_panel.rs index 687e67d..be75eb8 100644 --- a/src/ui/category_panel.rs +++ b/src/ui/category_panel.rs @@ -49,7 +49,7 @@ impl<'a> Widget for CategoryPanel<'a> { let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add; let (border_color, title) = if is_active { - (Color::Cyan, " Categories n:new d:del Space:axis ") + (Color::Cyan, " Categories ") } else { (Color::DarkGray, " Categories ") }; diff --git a/src/ui/effect.rs b/src/ui/effect.rs index 7420466..a4f26f3 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -97,15 +97,7 @@ pub struct EnterEditAtCursor; impl Effect for EnterEditAtCursor { fn apply(&self, app: &mut App) { let ctx = app.cmd_context(crossterm::event::KeyCode::Null, crossterm::event::KeyModifiers::NONE); - let value = if let Some(v) = &ctx.records_value { - v.clone() - } else { - ctx.cell_key - .as_ref() - .and_then(|k| ctx.model.get_cell(k).cloned()) - .map(|v| v.to_string()) - .unwrap_or_default() - }; + let value = ctx.display_value.clone(); drop(ctx); app.buffers.insert("edit".to_string(), value); app.mode = AppMode::Editing { @@ -406,7 +398,7 @@ pub struct StartDrill(pub Vec<(CellKey, CellValue)>); impl Effect for StartDrill { fn apply(&self, app: &mut App) { app.drill_state = Some(super::app::DrillState { - records: self.0.clone(), + records: std::rc::Rc::new(self.0.clone()), pending_edits: std::collections::HashMap::new(), }); } @@ -838,6 +830,16 @@ pub enum Panel { View, } +impl Panel { + pub fn mode(self) -> AppMode { + match self { + Panel::Formula => AppMode::FormulaPanel, + Panel::Category => AppMode::CategoryPanel, + Panel::View => AppMode::ViewPanel, + } + } +} + impl Effect for SetPanelOpen { fn apply(&self, app: &mut App) { match self.panel { diff --git a/src/ui/grid.rs b/src/ui/grid.rs index b97a23e..4725d07 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -6,7 +6,6 @@ use ratatui::{ }; use unicode_width::UnicodeWidthStr; -use crate::model::cell::CellValue; use crate::model::Model; use crate::ui::app::AppMode; use crate::view::{AxisEntry, GridLayout}; @@ -23,6 +22,7 @@ const GROUP_COLLAPSED: &str = "▶"; pub struct GridWidget<'a> { pub model: &'a Model, + pub layout: &'a GridLayout, pub mode: &'a AppMode, pub search_query: &'a str, pub buffers: &'a std::collections::HashMap, @@ -32,6 +32,7 @@ pub struct GridWidget<'a> { impl<'a> GridWidget<'a> { pub fn new( model: &'a Model, + layout: &'a GridLayout, mode: &'a AppMode, search_query: &'a str, buffers: &'a std::collections::HashMap, @@ -39,6 +40,7 @@ impl<'a> GridWidget<'a> { ) -> Self { Self { model, + layout, mode, search_query, buffers, @@ -46,23 +48,9 @@ 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(); - - let frozen = self.drill_state.map(|s| s.records.clone()); - let layout = GridLayout::with_frozen_records(self.model, view, frozen); + let layout = self.layout; let (sel_row, sel_col) = view.selected; let row_offset = view.row_offset; let col_offset = view.col_offset; @@ -71,56 +59,9 @@ 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 = { - 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; - } - } - } - } - } - // +1 for gap between columns - widths - .into_iter() - .map(|w| (w + 1).max(MIN_COL_WIDTH).min(MAX_COL_WIDTH)) - .collect() - }; + let col_widths = compute_col_widths(self.model, &layout, fmt_comma, fmt_decimals); // ── Adaptive row header widths ─────────────────────────────── - // Measure the widest label at each row-header level. let data_row_items: Vec<&Vec> = layout .row_items .iter() @@ -410,23 +351,15 @@ impl<'a> GridWidget<'a> { } let cw = col_w_at(ci) as usize; - let (cell_str, value) = if layout.is_records_mode() { - 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())) - } else { - None - }; - (s, v) + // Check pending drill edits first, then use display_text + let cell_str = if let Some(ds) = self.drill_state { + let col_name = layout.col_label(ci); + ds.pending_edits + .get(&(ri, col_name)) + .cloned() + .unwrap_or_else(|| layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)) } else { - let key = match layout.cell_key(ri, ci) { - Some(k) => k, - None => continue, - }; - let value = self.model.evaluate_aggregated(&key, &layout.none_cats); - let s = format_value(value.as_ref(), fmt_comma, fmt_decimals); - (s, value) + layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals) }; let is_selected = ri == sel_row && ci == sel_col; let is_search_match = !self.search_query.is_empty() @@ -453,13 +386,13 @@ impl<'a> GridWidget<'a> { } else if is_search_match { Style::default().fg(Color::Black).bg(Color::Yellow) } else if is_sel_row { - let fg = if value.is_none() { + let fg = if cell_str.is_empty() { Color::DarkGray } else { Color::White }; Style::default().fg(fg).bg(ROW_HIGHLIGHT_BG) - } else if value.is_none() { + } else if cell_str.is_empty() { Style::default().fg(Color::DarkGray) } else { Style::default() @@ -588,53 +521,111 @@ impl<'a> Widget for GridWidget<'a> { } } -fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String { - match v { - Some(CellValue::Number(n)) => format_f64(*n, comma, decimals), - Some(CellValue::Text(s)) => s.clone(), - None => String::new(), - } -} - -pub fn parse_number_format(fmt: &str) -> (bool, u8) { - let comma = fmt.contains(','); - let decimals = fmt - .rfind('.') - .and_then(|i| fmt[i + 1..].parse::().ok()) - .unwrap_or(0); - (comma, decimals) -} - -pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String { - let formatted = format!("{:.prec$}", n, prec = decimals as usize); - if !comma { - return formatted; - } - // Split integer and decimal parts - let (int_part, dec_part) = if let Some(dot) = formatted.find('.') { - (&formatted[..dot], Some(&formatted[dot..])) - } else { - (&formatted[..], None) - }; - let is_neg = int_part.starts_with('-'); - let digits = if is_neg { &int_part[1..] } else { int_part }; - let mut result = String::new(); - for (idx, c) in digits.chars().rev().enumerate() { - if idx > 0 && idx % 3 == 0 { - result.push(','); +/// 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 { + let n = layout.col_count(); + let mut widths = vec![0u16; n]; + // Measure individual header level labels + let data_col_items: Vec<&Vec> = 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; + } } - result.push(c); } - if is_neg { - result.push('-'); + // Measure cell content widths (works for both pivot and records modes) + for ri in 0..layout.row_count() { + for (ci, wref) in widths.iter_mut().enumerate().take(n) { + let s = layout.display_text(model, ri, ci, fmt_comma, fmt_decimals); + let w = s.width() as u16; + if w > *wref { + *wref = w; + } + } } - let mut out: String = result.chars().rev().collect(); - if let Some(dec) = dec_part { - out.push_str(dec); + // Measure total row (column sums) — pivot mode only + if !layout.is_records_mode() && 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; + } + } } - out + 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> = layout + .row_items + .iter() + .filter_map(|e| { + if let AxisEntry::DataItem(v) = e { + Some(v) + } else { + None + } + }) + .collect(); + 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(); + 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) +} + +// Re-export shared formatting functions +pub use crate::format::{format_f64, parse_number_format}; + fn truncate(s: &str, max_width: usize) -> String { let w = s.width(); if w <= max_width { @@ -674,7 +665,8 @@ mod tests { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); let bufs = std::collections::HashMap::new(); - GridWidget::new(model, &AppMode::Normal, "", &bufs, None).render(area, &mut buf); + let layout = GridLayout::new(model, model.active_view()); + GridWidget::new(model, &layout, &AppMode::Normal, "", &bufs, None).render(area, &mut buf); buf } diff --git a/src/ui/view_panel.rs b/src/ui/view_panel.rs index e087ddb..123eb97 100644 --- a/src/ui/view_panel.rs +++ b/src/ui/view_panel.rs @@ -36,7 +36,7 @@ impl<'a> Widget for ViewPanel<'a> { let block = Block::default() .borders(Borders::ALL) .border_style(border_style) - .title(" Views [Enter] switch [n]ew [d]elete "); + .title(" Views "); let inner = block.inner(area); block.render(area, buf);