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:
728
src/ui/app.rs
728
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<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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user