fix: navigation bounds and stale state after model load

- app.rs: scroll_rows (Ctrl+D/U) now clamps to the cross-product row
  count and follows the viewport, matching move_selection's behaviour.
  Previously it could push selected past the last row, causing
  selected_cell_key to return None and silently ignoring edits.
- model.rs: add normalize_view_state() which resets row/col offsets to
  zero on all views.
- main.rs, dispatch.rs, app.rs: call normalize_view_state() after every
  model replacement (initial load, :Load command, wizard import) so
  stale offsets from a previous session can't hide the grid.
- app.rs: clamp formula_cursor to the current formula list length at the
  top of handle_formula_panel_key so a model reload with fewer formulas
  can't leave the cursor pointing past the end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-23 23:32:26 -07:00
parent 0cb491901d
commit 45b848dc67
2 changed files with 21 additions and 4 deletions

View File

@ -73,7 +73,10 @@ fn main() -> Result<()> {
// Load or create model // Load or create model
let mut model = if let Some(ref path) = file_path { let mut model = if let Some(ref path) = file_path {
if path.exists() { if path.exists() {
persistence::load(path).with_context(|| format!("Failed to load {}", path.display()))? let mut m = persistence::load(path)
.with_context(|| format!("Failed to load {}", path.display()))?;
m.normalize_view_state();
m
} else { } else {
let name = path.file_stem() let name = path.file_stem()
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())

View File

@ -594,6 +594,11 @@ impl App {
// ── Panel key handlers ─────────────────────────────────────────────────── // ── Panel key handlers ───────────────────────────────────────────────────
fn handle_formula_panel_key(&mut self, key: KeyEvent) -> Result<()> { fn handle_formula_panel_key(&mut self, key: KeyEvent) -> Result<()> {
// Clamp cursor in case the formula list shrank since it was last set.
let flen = self.model.formulas.len();
if flen == 0 { self.formula_cursor = 0; }
else { self.formula_cursor = self.formula_cursor.min(flen - 1); }
match key.code { match key.code {
KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; } KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; }
KeyCode::Char('a') | KeyCode::Char('n') | KeyCode::Char('o') => { KeyCode::Char('a') | KeyCode::Char('n') | KeyCode::Char('o') => {
@ -882,8 +887,10 @@ impl App {
KeyCode::Backspace => wizard.pop_name_char(), KeyCode::Backspace => wizard.pop_name_char(),
KeyCode::Enter => { KeyCode::Enter => {
match wizard.build_model() { match wizard.build_model() {
Ok(model) => { Ok(mut model) => {
model.normalize_view_state();
self.model = model; self.model = model;
self.formula_cursor = 0;
self.dirty = true; self.dirty = true;
self.status_msg = "Import successful! Press :w <path> to save.".to_string(); self.status_msg = "Import successful! Press :w <path> to save.".to_string();
self.mode = AppMode::Normal; self.mode = AppMode::Normal;
@ -956,9 +963,16 @@ impl App {
} }
fn scroll_rows(&mut self, delta: i32) { fn scroll_rows(&mut self, delta: i32) {
let row_max = {
let view = match self.model.active_view() { Some(v) => v, None => return };
let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
cross_product_strs(&row_cats, &self.model, view).len().saturating_sub(1)
};
if let Some(view) = self.model.active_view_mut() { if let Some(view) = self.model.active_view_mut() {
let new_r = (view.selected.0 as i32 + delta).max(0) as usize; let nr = (view.selected.0 as i32 + delta).clamp(0, row_max as i32) as usize;
view.selected.0 = new_r; view.selected.0 = nr;
if nr < view.row_offset { view.row_offset = nr; }
if nr >= view.row_offset + 20 { view.row_offset = nr.saturating_sub(19); }
} }
} }