diff --git a/src/ui/app.rs b/src/ui/app.rs index 1e73525..b9171d8 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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, /// Yanked cell value for `p` paste pub yanked: Option, - keymap: Keymap, + /// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.) + pub transient_keymap: Option>, + 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 = (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, usize)> { - let page_cats: Vec = self - .model - .active_view() - .categories_on(Axis::Page) - .into_iter() - .map(String::from) - .collect(); - page_cats - .into_iter() - .filter_map(|cat| { - let items: Vec = 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 = 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 = 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 { @@ -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)); }