overhaul keymap handling and remove pending key

- Updated imports to include Arc and removed KeyModifiers.
- Replaced pending_key with transient_keymap and keymap_set.
- Added KeymapSet for mode-specific keymaps.
- Removed legacy pending key logic and many helper methods.
- Updated tests to use new command execution pattern.
- Adjusted App struct and methods to align with new keymap system.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gpt-oss:20b)
This commit is contained in:
Edward Langley
2026-04-04 09:31:49 -07:00
parent dfae4a882d
commit 387190c9f7

View File

@ -1,16 +1,17 @@
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::{KeyCode, KeyEvent};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::command::cmd::CmdContext;
use crate::command::keymap::Keymap;
use crate::command::cmd::{Cmd, CmdContext};
use crate::command::keymap::{Keymap, KeymapSet};
use crate::command::{self, Command};
use crate::import::wizard::{ImportWizard, WizardStep};
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::persistence;
use crate::view::{Axis, AxisEntry, GridLayout};
use crate::view::{Axis, GridLayout};
#[derive(Debug, Clone, PartialEq)]
pub enum AppMode {
@ -64,11 +65,11 @@ pub struct App {
pub view_panel_cursor: usize,
pub formula_cursor: usize,
pub dirty: bool,
/// Pending key for two-key sequences (g→gg, y→yy, d→dd)
pub pending_key: Option<char>,
/// Yanked cell value for `p` paste
pub yanked: Option<CellValue>,
keymap: Keymap,
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
pub transient_keymap: Option<Arc<Keymap>>,
keymap_set: KeymapSet,
}
impl App {
@ -89,9 +90,9 @@ impl App {
view_panel_cursor: 0,
formula_cursor: 0,
dirty: false,
pending_key: None,
yanked: None,
keymap: Keymap::default_keymap(),
transient_keymap: None,
keymap_set: KeymapSet::default_keymaps(),
}
}
@ -105,9 +106,11 @@ impl App {
col_offset: view.col_offset,
search_query: &self.search_query,
yanked: &self.yanked,
pending_key: self.pending_key,
dirty: self.dirty,
file_path_set: self.file_path.is_some(),
formula_panel_open: self.formula_panel_open,
category_panel_open: self.category_panel_open,
view_panel_open: self.view_panel_open,
}
}
@ -123,335 +126,47 @@ impl App {
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
// Try keymap first — if a binding matches, apply effects and return
// Transient keymap (prefix key sequence) takes priority
if let Some(transient) = self.transient_keymap.take() {
let ctx = self.cmd_context();
if let Some(effects) = transient.dispatch(&ctx, key.code, key.modifiers) {
drop(ctx);
self.apply_effects(effects);
}
// Whether matched or not, transient is consumed
return Ok(());
}
// Try mode keymap — if a binding matches, apply effects and return
let ctx = self.cmd_context();
if let Some(effects) = self.keymap.dispatch(&ctx, key.code, key.modifiers) {
if let Some(effects) = self.keymap_set.dispatch(&ctx, key.code, key.modifiers) {
drop(ctx);
self.apply_effects(effects);
return Ok(());
}
drop(ctx);
// Fallback: old-style handlers for modes not yet migrated to keymaps
match &self.mode.clone() {
AppMode::Quit => {}
AppMode::Help => {
// Handled by keymap now, but keep as fallback
self.mode = AppMode::Normal;
}
AppMode::ImportWizard => {
self.handle_wizard_key(key)?;
}
AppMode::Editing { .. } => {
self.handle_edit_key(key)?;
}
AppMode::FormulaEdit { .. } => {
self.handle_formula_edit_key(key)?;
}
AppMode::FormulaPanel => {
self.handle_formula_panel_key(key)?;
}
AppMode::CategoryPanel => {
self.handle_category_panel_key(key)?;
}
AppMode::CategoryAdd { .. } => {
self.handle_category_add_key(key)?;
}
AppMode::ItemAdd { .. } => {
self.handle_item_add_key(key)?;
}
AppMode::ViewPanel => {
self.handle_view_panel_key(key)?;
}
AppMode::TileSelect { .. } => {
self.handle_tile_select_key(key)?;
}
AppMode::ExportPrompt { .. } => {
self.handle_export_key(key)?;
}
AppMode::CommandMode { .. } => {
self.handle_command_mode_key(key)?;
}
AppMode::Normal => {
self.handle_normal_key(key)?;
}
}
Ok(())
}
fn handle_normal_key(&mut self, key: KeyEvent) -> Result<()> {
if self.search_mode {
return self.handle_search_key(key);
}
// Handle two-key sequences first
if let Some(prev) = self.pending_key.take() {
return self.handle_two_key(prev, key);
}
match (key.code, key.modifiers) {
// ── Quit / Help ────────────────────────────────────────────────
(KeyCode::Char('q'), KeyModifiers::CONTROL) => {
self.mode = AppMode::Quit;
}
// ZZ = save and quit
(KeyCode::Char('Z'), KeyModifiers::SHIFT) => {
self.pending_key = Some('Z');
}
(KeyCode::F(1), _) | (KeyCode::Char('?'), KeyModifiers::NONE) => {
self.mode = AppMode::Help;
}
// ── File ops ───────────────────────────────────────────────────
(KeyCode::Char('s'), KeyModifiers::CONTROL) => {
self.save()?;
}
// ── Command line ───────────────────────────────────────────────
(KeyCode::Char(':'), _) => {
self.mode = AppMode::CommandMode {
buffer: String::new(),
};
}
// ── Panel toggles (uppercase letter = no modifier needed) ──────
(KeyCode::Char('F'), KeyModifiers::SHIFT) | (KeyCode::Char('F'), _) => {
self.formula_panel_open = !self.formula_panel_open;
if self.formula_panel_open {
self.mode = AppMode::FormulaPanel;
// Normal mode keys are handled by the keymap above.
// Only search sub-mode still uses the old pattern.
if self.search_mode {
self.handle_search_key(key)?;
}
}
(KeyCode::Char('C'), KeyModifiers::SHIFT) | (KeyCode::Char('C'), _) => {
self.category_panel_open = !self.category_panel_open;
if self.category_panel_open {
self.mode = AppMode::CategoryPanel;
}
}
(KeyCode::Char('V'), KeyModifiers::SHIFT) => {
self.view_panel_open = !self.view_panel_open;
if self.view_panel_open {
self.mode = AppMode::ViewPanel;
}
}
// Legacy Ctrl+ panel toggles still work
(KeyCode::Char('f'), KeyModifiers::CONTROL) => {
self.formula_panel_open = !self.formula_panel_open;
}
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
self.category_panel_open = !self.category_panel_open;
}
(KeyCode::Char('v'), KeyModifiers::CONTROL) => {
self.view_panel_open = !self.view_panel_open;
}
(KeyCode::Char('e'), KeyModifiers::CONTROL) => {
self.mode = AppMode::ExportPrompt {
buffer: String::new(),
};
}
// ── Tab cycles open panels ─────────────────────────────────────
(KeyCode::Tab, _) => {
if self.formula_panel_open {
self.mode = AppMode::FormulaPanel;
} else if self.category_panel_open {
self.mode = AppMode::CategoryPanel;
} else if self.view_panel_open {
self.mode = AppMode::ViewPanel;
}
}
// ── Tile movement (Ctrl+Arrow) — must come before plain arrows ──
(KeyCode::Left, KeyModifiers::CONTROL)
| (KeyCode::Right, KeyModifiers::CONTROL)
| (KeyCode::Up, KeyModifiers::CONTROL)
| (KeyCode::Down, KeyModifiers::CONTROL) => {
let count = self.model.category_names().len();
if count > 0 {
self.mode = AppMode::TileSelect { cat_idx: 0 };
}
}
// ── Navigation ─────────────────────────────────────────────────
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
self.move_selection(-1, 0);
}
(KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => {
self.move_selection(1, 0);
}
(KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => {
self.move_selection(0, -1);
}
(KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => {
self.move_selection(0, 1);
}
// G = last row, gg = first row (g sets pending)
(KeyCode::Char('G'), _) => {
self.jump_to_last_row();
}
(KeyCode::Char('g'), KeyModifiers::NONE) => {
self.pending_key = Some('g');
}
// 0 = first col, $ = last col
(KeyCode::Char('0'), KeyModifiers::NONE) => {
let view = self.model.active_view_mut();
view.selected.1 = 0;
view.col_offset = 0;
}
(KeyCode::Char('$'), _) => {
self.jump_to_last_col();
}
// Ctrl+D / Ctrl+U = half-page scroll
(KeyCode::Char('d'), KeyModifiers::CONTROL) => {
self.scroll_rows(5);
}
(KeyCode::Char('u'), KeyModifiers::CONTROL) => {
self.scroll_rows(-5);
}
// Enter = advance (down, wrapping to top of next column)
(KeyCode::Enter, _) => {
self.enter_advance();
}
// ── Editing ────────────────────────────────────────────────────
(KeyCode::Char('i'), KeyModifiers::NONE) | (KeyCode::Char('a'), KeyModifiers::NONE) => {
let current = self
.selected_cell_key()
.and_then(|k| self.model.get_cell(&k).cloned())
.map(|v| v.to_string())
.unwrap_or_default();
self.mode = AppMode::Editing { buffer: current };
}
// x = clear cell
(KeyCode::Char('x'), KeyModifiers::NONE) => {
if let Some(key) = self.selected_cell_key() {
let cmd = Command::ClearCell {
coords: key.0.iter().map(|(c, v)| [c.clone(), v.clone()]).collect(),
};
command::dispatch(&mut self.model, &cmd);
self.dirty = true;
}
}
// y = start yank sequence (yy = yank cell)
(KeyCode::Char('y'), KeyModifiers::NONE) => {
self.pending_key = Some('y');
}
// p = paste yanked value
(KeyCode::Char('p'), KeyModifiers::NONE) => {
if let Some(value) = self.yanked.clone() {
if let Some(key) = self.selected_cell_key() {
let coords = key.0.iter().map(|(c, v)| [c.clone(), v.clone()]).collect();
let cmd = match &value {
CellValue::Number(n) => Command::SetCell {
coords,
value: crate::command::types::CellValueArg::Number { number: *n },
},
CellValue::Text(t) => Command::SetCell {
coords,
value: crate::command::types::CellValueArg::Text {
text: t.clone(),
},
},
};
command::dispatch(&mut self.model, &cmd);
self.dirty = true;
}
}
}
// ── Search ─────────────────────────────────────────────────────
(KeyCode::Char('/'), _) => {
self.search_mode = true;
self.search_query.clear();
}
(KeyCode::Char('n'), KeyModifiers::NONE) => {
if !self.search_query.is_empty() {
self.search_navigate(true);
}
}
(KeyCode::Char('N'), _) => {
if !self.search_query.is_empty() {
self.search_navigate(false);
} else {
// N with no active search = quick-add a new category
self.category_panel_open = true;
self.mode = AppMode::CategoryAdd {
buffer: String::new(),
};
}
}
// ── Grid transpose ─────────────────────────────────────────────
(KeyCode::Char('t'), KeyModifiers::NONE) => {
self.model.active_view_mut().transpose_axes();
}
// ── Tile movement ──────────────────────────────────────────────
// T = enter tile select mode (single key, no Ctrl needed)
(KeyCode::Char('T'), _) => {
let count = self.model.category_names().len();
if count > 0 {
self.mode = AppMode::TileSelect { cat_idx: 0 };
}
}
// ── Page axis ──────────────────────────────────────────────────
(KeyCode::Char('['), _) => {
self.page_prev();
}
(KeyCode::Char(']'), _) => {
self.page_next();
}
// ── Group collapse toggle ───────────────────────────────────────
(KeyCode::Char('z'), KeyModifiers::NONE) => {
self.toggle_group_under_cursor();
}
// ── Hide row item ───────────────────────────────────────────────
(KeyCode::Char('H'), _) => {
self.hide_selected_row_item();
}
_ => {}
}
Ok(())
}
/// Handle the second key of a two-key sequence.
fn handle_two_key(&mut self, first: char, key: KeyEvent) -> Result<()> {
match (first, key.code) {
// gg = first row
('g', KeyCode::Char('g')) => {
let view = self.model.active_view_mut();
view.selected = (0, view.selected.1);
view.row_offset = 0;
}
// gz = toggle column group under cursor
('g', KeyCode::Char('z')) => {
self.toggle_col_group_under_cursor();
}
// yy = yank current cell
('y', KeyCode::Char('y')) => {
if let Some(key) = self.selected_cell_key() {
let layout = GridLayout::new(&self.model, self.model.active_view());
self.yanked = self.model.evaluate_aggregated(&key, &layout.none_cats);
self.status_msg = "Yanked".to_string();
}
}
// ZZ = save + quit
('Z', KeyCode::Char('Z')) => {
self.save()?;
self.mode = AppMode::Quit;
}
// Unrecognised two-key — treat second key normally
_ => {
return self.handle_normal_key(key);
}
AppMode::Editing { .. } => self.handle_edit_key(key)?,
AppMode::FormulaEdit { .. } => self.handle_formula_edit_key(key)?,
AppMode::FormulaPanel => self.handle_formula_panel_key(key)?,
AppMode::CategoryPanel => self.handle_category_panel_key(key)?,
AppMode::CategoryAdd { .. } => self.handle_category_add_key(key)?,
AppMode::ItemAdd { .. } => self.handle_item_add_key(key)?,
AppMode::ViewPanel => self.handle_view_panel_key(key)?,
AppMode::TileSelect { .. } => self.handle_tile_select_key(key)?,
AppMode::ExportPrompt { .. } => self.handle_export_key(key)?,
AppMode::CommandMode { .. } => self.handle_command_mode_key(key)?,
AppMode::ImportWizard => self.handle_wizard_key(key)?,
AppMode::Quit | AppMode::Help => {}
}
Ok(())
}
@ -742,7 +457,11 @@ impl App {
self.dirty = true;
}
self.mode = AppMode::Normal;
self.move_selection(1, 0);
// Advance cursor down after committing edit
let ctx = self.cmd_context();
let effects = crate::command::cmd::MoveSelection { dr: 1, dc: 0 }.execute(&ctx);
drop(ctx);
self.apply_effects(effects);
}
KeyCode::Char(c) => {
if let AppMode::Editing { buffer } = &mut self.mode {
@ -1306,312 +1025,6 @@ impl App {
Ok(())
}
// ── Group collapse ───────────────────────────────────────────────────────
fn toggle_group_under_cursor(&mut self) {
let layout = GridLayout::new(&self.model, self.model.active_view());
let sel_row = self.model.active_view().selected.0;
let Some((cat, group)) = layout.row_group_for(sel_row) else {
return;
};
let cmd = Command::ToggleGroup {
category: cat,
group,
};
command::dispatch(&mut self.model, &cmd);
self.dirty = true;
}
fn toggle_col_group_under_cursor(&mut self) {
let layout = GridLayout::new(&self.model, self.model.active_view());
let sel_col = self.model.active_view().selected.1;
let Some((cat, group)) = layout.col_group_for(sel_col) else {
return;
};
let cmd = Command::ToggleGroup {
category: cat,
group,
};
command::dispatch(&mut self.model, &cmd);
// Clamp selection if col_count shrank
let new_count = GridLayout::new(&self.model, self.model.active_view()).col_count();
let view = self.model.active_view_mut();
if view.selected.1 >= new_count && new_count > 0 {
view.selected.1 = new_count - 1;
}
self.dirty = true;
}
fn hide_selected_row_item(&mut self) {
let layout = GridLayout::new(&self.model, self.model.active_view());
let Some(cat_name) = layout.row_cats.first().cloned() else {
return;
};
let sel_row = self.model.active_view().selected.0;
let Some(items) = layout
.row_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
}
})
.nth(sel_row)
else {
return;
};
let item_name = items[0].clone();
command::dispatch(
&mut self.model,
&Command::HideItem {
category: cat_name,
item: item_name,
},
);
// Clamp selection in case it's now out of bounds
let row_max = GridLayout::new(&self.model, self.model.active_view())
.row_count()
.saturating_sub(1);
let sel = self.model.active_view().selected;
self.model.active_view_mut().selected = (sel.0.min(row_max), sel.1);
self.dirty = true;
}
// ── Motion helpers ───────────────────────────────────────────────────────
fn move_selection(&mut self, dr: i32, dc: i32) {
let (row_max, col_max) = {
let layout = GridLayout::new(&self.model, self.model.active_view());
(
layout.row_count().saturating_sub(1),
layout.col_count().saturating_sub(1),
)
};
let view = self.model.active_view_mut();
let (r, c) = view.selected;
let nr = (r as i32 + dr).clamp(0, row_max as i32) as usize;
let nc = (c as i32 + dc).clamp(0, col_max as i32) as usize;
view.selected = (nr, nc);
// Keep cursor in visible area (approximate viewport: 20 rows, 8 cols)
if nr < view.row_offset {
view.row_offset = nr;
}
if nr >= view.row_offset + 20 {
view.row_offset = nr.saturating_sub(19);
}
if nc < view.col_offset {
view.col_offset = nc;
}
if nc >= view.col_offset + 8 {
view.col_offset = nc.saturating_sub(7);
}
}
fn jump_to_last_row(&mut self) {
let count = GridLayout::new(&self.model, self.model.active_view())
.row_count()
.saturating_sub(1);
let view = self.model.active_view_mut();
view.selected.0 = count;
if count >= view.row_offset + 20 {
view.row_offset = count.saturating_sub(19);
}
}
fn jump_to_last_col(&mut self) {
let count = GridLayout::new(&self.model, self.model.active_view())
.col_count()
.saturating_sub(1);
let view = self.model.active_view_mut();
view.selected.1 = count;
if count >= view.col_offset + 8 {
view.col_offset = count.saturating_sub(7);
}
}
fn scroll_rows(&mut self, delta: i32) {
let row_max = GridLayout::new(&self.model, self.model.active_view())
.row_count()
.saturating_sub(1);
let view = self.model.active_view_mut();
let nr = (view.selected.0 as i32 + delta).clamp(0, row_max as i32) as usize;
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);
}
}
/// Navigate to the next (`forward=true`) or previous (`forward=false`) cell
/// whose display value contains `self.search_query` (case-insensitive).
fn search_navigate(&mut self, forward: bool) {
let query = self.search_query.to_lowercase();
if query.is_empty() {
return;
}
let view = self.model.active_view();
let layout = GridLayout::new(&self.model, view);
let (cur_row, cur_col) = view.selected;
let total_rows = layout.row_count().max(1);
let total_cols = layout.col_count().max(1);
let total = total_rows * total_cols;
let cur_flat = cur_row * total_cols + cur_col;
let matches: Vec<usize> = (0..total)
.filter(|&flat| {
let ri = flat / total_cols;
let ci = flat % total_cols;
let key = match layout.cell_key(ri, ci) {
Some(k) => k,
None => return false,
};
let s = match self.model.evaluate_aggregated(&key, &layout.none_cats) {
Some(CellValue::Number(n)) => format!("{n}"),
Some(CellValue::Text(t)) => t,
None => String::new(),
};
s.to_lowercase().contains(&query)
})
.collect();
if matches.is_empty() {
self.status_msg = format!("No matches for '{}'", self.search_query);
return;
}
// Find next/prev match relative to current position
let target_flat = if forward {
matches
.iter()
.find(|&&f| f > cur_flat)
.or_else(|| matches.first())
.copied()
} else {
matches
.iter()
.rev()
.find(|&&f| f < cur_flat)
.or_else(|| matches.last())
.copied()
};
if let Some(flat) = target_flat {
let ri = flat / total_cols;
let ci = flat % total_cols;
{
let view = self.model.active_view_mut();
view.selected = (ri, ci);
// Adjust scroll offsets to keep cursor visible
if ri < view.row_offset {
view.row_offset = ri;
}
if ci < view.col_offset {
view.col_offset = ci;
}
}
self.status_msg = format!(
"Match {}/{} for '{}'",
matches.iter().position(|&f| f == flat).unwrap_or(0) + 1,
matches.len(),
self.search_query
);
}
}
/// Gather (cat_name, items, current_idx) for all non-empty page categories.
fn page_cat_data(&self) -> Vec<(String, Vec<String>, usize)> {
let page_cats: Vec<String> = self
.model
.active_view()
.categories_on(Axis::Page)
.into_iter()
.map(String::from)
.collect();
page_cats
.into_iter()
.filter_map(|cat| {
let items: Vec<String> = self
.model
.category(&cat)
.map(|c| {
c.ordered_item_names()
.into_iter()
.map(String::from)
.collect()
})
.unwrap_or_default();
if items.is_empty() {
return None;
}
let current = self
.model
.active_view()
.page_selection(&cat)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_default();
let idx = items.iter().position(|i| *i == current).unwrap_or(0);
Some((cat, items, idx))
})
.collect()
}
fn page_next(&mut self) {
let data = self.page_cat_data();
if data.is_empty() {
return;
}
// Odometer: advance from last category, carry propagates backward.
let mut indices: Vec<usize> = data.iter().map(|(_, _, i)| *i).collect();
let mut carry = true;
for i in (0..data.len()).rev() {
if !carry {
break;
}
indices[i] += 1;
if indices[i] >= data[i].1.len() {
indices[i] = 0;
} else {
carry = false;
}
}
let view = self.model.active_view_mut();
for (i, (cat, items, _)) in data.iter().enumerate() {
view.set_page_selection(cat, &items[indices[i]]);
}
}
fn page_prev(&mut self) {
let data = self.page_cat_data();
if data.is_empty() {
return;
}
// Odometer: decrement from last category, borrow propagates backward.
let mut indices: Vec<usize> = data.iter().map(|(_, _, i)| *i).collect();
let mut borrow = true;
for i in (0..data.len()).rev() {
if !borrow {
break;
}
if indices[i] == 0 {
indices[i] = data[i].1.len().saturating_sub(1);
} else {
indices[i] -= 1;
borrow = false;
}
}
let view = self.model.active_view_mut();
for (i, (cat, items, _)) in data.iter().enumerate() {
view.set_page_selection(cat, &items[indices[i]]);
}
}
// ── Cell key resolution ──────────────────────────────────────────────────
pub fn selected_cell_key(&self) -> Option<CellKey> {
@ -1648,40 +1061,6 @@ impl App {
self.mode = AppMode::ImportWizard;
}
/// Advance selection down one row; when at the last row, wrap to row 0 of
/// the next column (typewriter-style). Does nothing if the grid is empty.
pub fn enter_advance(&mut self) {
let (row_max, col_max) = {
let layout = GridLayout::new(&self.model, self.model.active_view());
(
layout.row_count().saturating_sub(1),
layout.col_count().saturating_sub(1),
)
};
let view = self.model.active_view_mut();
let (r, c) = view.selected;
let (nr, nc) = if r < row_max {
(r + 1, c)
} else if c < col_max {
(0, c + 1)
} else {
(r, c) // already at bottom-right; stay
};
view.selected = (nr, nc);
if nr < view.row_offset {
view.row_offset = nr;
}
if nr >= view.row_offset + 20 {
view.row_offset = nr.saturating_sub(19);
}
if nc < view.col_offset {
view.col_offset = nc;
}
if nc >= view.col_offset + 8 {
view.col_offset = nc.saturating_sub(7);
}
}
/// Hint text for the status bar (context-sensitive)
pub fn hint_text(&self) -> &'static str {
match &self.mode {
@ -1718,11 +1097,18 @@ mod tests {
App::new(m, None)
}
fn run_cmd(app: &mut App, cmd: &dyn crate::command::cmd::Cmd) {
let ctx = app.cmd_context();
let effects = cmd.execute(&ctx);
drop(ctx);
app.apply_effects(effects);
}
#[test]
fn enter_advance_moves_down_within_column() {
let mut app = two_col_model();
app.model.active_view_mut().selected = (0, 0);
app.enter_advance();
run_cmd(&mut app, &crate::command::cmd::EnterAdvance);
assert_eq!(app.model.active_view().selected, (1, 0));
}
@ -1731,7 +1117,7 @@ mod tests {
let mut app = two_col_model();
// row_max = 2 (A,B,C), col 0 → should wrap to (0, 1)
app.model.active_view_mut().selected = (2, 0);
app.enter_advance();
run_cmd(&mut app, &crate::command::cmd::EnterAdvance);
assert_eq!(app.model.active_view().selected, (0, 1));
}