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

@ -11,6 +11,7 @@ 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
@ -377,6 +378,137 @@ 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
// (8030)/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 (8030)/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 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;