use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::model::Model; use crate::model::cell::{CellKey, CellValue}; use crate::import::wizard::{ImportWizard, WizardStep}; use crate::persistence; use crate::view::{Axis, GridLayout}; use crate::command::{self, Command}; #[derive(Debug, Clone, PartialEq)] pub enum AppMode { Normal, Editing { buffer: String }, FormulaEdit { buffer: String }, FormulaPanel, CategoryPanel, /// Quick-add a new category: Enter adds and stays open, Esc closes. CategoryAdd { buffer: String }, /// Quick-add items to `category`: Enter adds and stays open, Esc closes. ItemAdd { category: String, buffer: String }, ViewPanel, TileSelect { cat_idx: usize }, ImportWizard, ExportPrompt { buffer: String }, /// Vim-style `:` command line CommandMode { buffer: String }, Help, Quit, } pub struct App { pub model: Model, pub file_path: Option, pub mode: AppMode, pub status_msg: String, pub wizard: Option, pub last_autosave: Instant, pub search_query: String, pub search_mode: bool, pub formula_panel_open: bool, pub category_panel_open: bool, pub view_panel_open: bool, pub cat_panel_cursor: usize, 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, } impl App { pub fn new(model: Model, file_path: Option) -> Self { Self { model, file_path, mode: AppMode::Normal, status_msg: String::new(), wizard: None, last_autosave: Instant::now(), search_query: String::new(), search_mode: false, formula_panel_open: false, category_panel_open: false, view_panel_open: false, cat_panel_cursor: 0, view_panel_cursor: 0, formula_cursor: 0, dirty: false, pending_key: None, yanked: None, } } /// True when the model has no categories yet (show welcome screen) pub fn is_empty_model(&self) -> bool { self.model.categories.is_empty() } pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> { match &self.mode.clone() { AppMode::Quit => {} AppMode::Help => { 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; } } (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) => { if let Some(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); } // ── Editing ──────────────────────────────────────────────────── (KeyCode::Enter, _) | (KeyCode::Char('i'), KeyModifiers::NONE) | (KeyCode::Char('a'), KeyModifiers::NONE) => { let current = self.selected_cell_key() .map(|k| self.model.get_cell(&k).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() } }, CellValue::Empty => Command::ClearCell { coords }, }; 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() }; } } // ── 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(); } _ => {} } 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')) => { if let Some(view) = self.model.active_view_mut() { view.selected = (0, view.selected.1); view.row_offset = 0; } } // yy = yank current cell ('y', KeyCode::Char('y')) => { if let Some(key) = self.selected_cell_key() { let val = self.model.evaluate(&key); self.yanked = Some(val); 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); } } Ok(()) } fn handle_search_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc | KeyCode::Enter => { self.search_mode = false; } KeyCode::Char(c) => { self.search_query.push(c); } KeyCode::Backspace => { self.search_query.pop(); } _ => {} } Ok(()) } // ── Command mode ──────────────────────────────────────────────────────── fn handle_command_mode_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc => { self.mode = AppMode::Normal; } KeyCode::Enter => { let buf = if let AppMode::CommandMode { buffer } = &self.mode { buffer.clone() } else { return Ok(()); }; self.execute_command(&buf)?; if !matches!(self.mode, AppMode::Quit) { self.mode = AppMode::Normal; } } KeyCode::Char(c) => { if let AppMode::CommandMode { buffer } = &mut self.mode { buffer.push(c); } } KeyCode::Backspace => { if let AppMode::CommandMode { buffer } = &mut self.mode { if buffer.is_empty() { self.mode = AppMode::Normal; } else { buffer.pop(); } } } _ => {} } Ok(()) } fn execute_command(&mut self, raw: &str) -> Result<()> { let raw = raw.trim(); let (cmd_name, rest) = raw.split_once(char::is_whitespace) .map(|(c, r)| (c, r.trim())) .unwrap_or((raw, "")); match cmd_name { "q" | "quit" => { if self.dirty { self.status_msg = "Unsaved changes. Use :q! to force quit or :wq to save+quit.".to_string(); } else { self.mode = AppMode::Quit; } } "q!" => { self.mode = AppMode::Quit; } "w" | "write" => { if rest.is_empty() { self.save()?; } else { let path = PathBuf::from(rest); persistence::save(&self.model, &path)?; self.file_path = Some(path.clone()); self.dirty = false; self.status_msg = format!("Saved to {}", path.display()); } } "wq" | "x" => { self.save()?; self.mode = AppMode::Quit; } "import" => { if rest.is_empty() { self.status_msg = "Usage: :import ".to_string(); } else { match std::fs::read_to_string(rest) { Ok(content) => match serde_json::from_str::(&content) { Ok(json) => { self.wizard = Some(ImportWizard::new(json)); self.mode = AppMode::ImportWizard; } Err(e) => { self.status_msg = format!("JSON parse error: {e}"); } } Err(e) => { self.status_msg = format!("Cannot read file: {e}"); } } } } "export" => { let path = if rest.is_empty() { "export.csv" } else { rest }; let view_name = self.model.active_view.clone(); match persistence::export_csv(&self.model, &view_name, Path::new(path)) { Ok(_) => { self.status_msg = format!("Exported to {path}"); } Err(e) => { self.status_msg = format!("Export error: {e}"); } } } "add-cat" | "add-category" | "cat" => { if rest.is_empty() { self.status_msg = "Usage: :add-cat ".to_string(); } else { let result = command::dispatch(&mut self.model, &Command::AddCategory { name: rest.to_string() }); self.status_msg = result.message.unwrap_or_default(); self.dirty = true; } } "add-item" | "item" => { // :add-item let mut parts = rest.splitn(2, char::is_whitespace); let cat = parts.next().unwrap_or("").trim(); let item = parts.next().unwrap_or("").trim(); if cat.is_empty() || item.is_empty() { self.status_msg = "Usage: :add-item ".to_string(); } else { let result = command::dispatch(&mut self.model, &Command::AddItem { category: cat.to_string(), item: item.to_string(), }); self.status_msg = result.message.unwrap_or_else(|| "Item added".to_string()); self.dirty = true; } } "add-items" | "items" => { // :add-items item1 item2 item3 ... let mut parts = rest.splitn(2, char::is_whitespace); let cat = parts.next().unwrap_or("").trim().to_string(); let items_str = parts.next().unwrap_or("").trim().to_string(); if cat.is_empty() || items_str.is_empty() { self.status_msg = "Usage: :add-items item1 item2 ...".to_string(); } else { let items: Vec<&str> = items_str.split_whitespace().collect(); let count = items.len(); for item in &items { command::dispatch(&mut self.model, &Command::AddItem { category: cat.clone(), item: item.to_string(), }); } self.status_msg = format!("Added {count} items to \"{cat}\"."); self.dirty = true; } } "formula" | "add-formula" => { if rest.is_empty() { self.formula_panel_open = true; self.mode = AppMode::FormulaPanel; } else { // :formula let mut parts = rest.splitn(2, char::is_whitespace); let cat = parts.next().unwrap_or("").trim(); let formula = parts.next().unwrap_or("").trim(); if cat.is_empty() || formula.is_empty() { self.status_msg = "Usage: :formula ".to_string(); } else { let result = command::dispatch(&mut self.model, &Command::AddFormula { raw: formula.to_string(), target_category: cat.to_string(), }); self.status_msg = result.message.unwrap_or_else(|| "Formula added".to_string()); self.dirty = true; } } } "add-view" | "view" => { let name = if rest.is_empty() { format!("View {}", self.model.views.len() + 1) } else { rest.to_string() }; command::dispatch(&mut self.model, &Command::CreateView { name: name.clone() }); let _ = command::dispatch(&mut self.model, &Command::SwitchView { name }); self.dirty = true; } "set-format" | "fmt" => { // :set-format e.g. ",.2" ",.0" ".2" // "," = comma separators; ".N" = N decimal places if rest.is_empty() { self.status_msg = "Usage: :set-format e.g. ,.0 ,.2 .4".to_string(); } else { if let Some(view) = self.model.active_view_mut() { view.number_format = rest.to_string(); } self.status_msg = format!("Number format set to '{rest}'"); self.dirty = true; } } "help" | "h" => { self.mode = AppMode::Help; } "" => {} // just pressed Enter with empty buffer other => { self.status_msg = format!("Unknown command: :{other} (try :help)"); } } Ok(()) } // ── Edit mode ──────────────────────────────────────────────────────────── fn handle_edit_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc => { self.mode = AppMode::Normal; } KeyCode::Enter => { let buf = if let AppMode::Editing { buffer } = &self.mode { buffer.clone() } else { return Ok(()); }; if let Some(key) = self.selected_cell_key() { let coords = key.0.iter().map(|(c, v)| [c.clone(), v.clone()]).collect(); let cmd = if buf.is_empty() { Command::ClearCell { coords } } else if let Ok(n) = buf.parse::() { Command::SetCell { coords, value: crate::command::types::CellValueArg::Number { number: n } } } else { Command::SetCell { coords, value: crate::command::types::CellValueArg::Text { text: buf.clone() } } }; command::dispatch(&mut self.model, &cmd); self.dirty = true; } self.mode = AppMode::Normal; self.move_selection(1, 0); } KeyCode::Char(c) => { if let AppMode::Editing { buffer } = &mut self.mode { buffer.push(c); } } KeyCode::Backspace => { if let AppMode::Editing { buffer } = &mut self.mode { buffer.pop(); } } _ => {} } Ok(()) } // ── Formula edit ───────────────────────────────────────────────────────── fn handle_formula_edit_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc => { self.mode = AppMode::FormulaPanel; } KeyCode::Enter => { let buf = if let AppMode::FormulaEdit { buffer } = &self.mode { buffer.clone() } else { return Ok(()); }; let first_cat = self.model.category_names().into_iter().next().map(String::from); if let Some(cat) = first_cat { let result = command::dispatch(&mut self.model, &Command::AddFormula { raw: buf, target_category: cat, }); self.status_msg = result.message.unwrap_or_else(|| "Formula added".to_string()); self.dirty = true; } else { self.status_msg = "Add at least one category first.".to_string(); } self.mode = AppMode::FormulaPanel; } KeyCode::Char(c) => { if let AppMode::FormulaEdit { buffer } = &mut self.mode { buffer.push(c); } } KeyCode::Backspace => { if let AppMode::FormulaEdit { buffer } = &mut self.mode { buffer.pop(); } } _ => {} } Ok(()) } // ── Panel key handlers ─────────────────────────────────────────────────── 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 { KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; } KeyCode::Char('a') | KeyCode::Char('n') | KeyCode::Char('o') => { self.mode = AppMode::FormulaEdit { buffer: String::new() }; } KeyCode::Char('d') | KeyCode::Delete => { if self.formula_cursor < self.model.formulas.len() { let f = &self.model.formulas[self.formula_cursor]; let target = f.target.clone(); let target_category = f.target_category.clone(); command::dispatch(&mut self.model, &Command::RemoveFormula { target, target_category }); if self.formula_cursor > 0 { self.formula_cursor -= 1; } self.dirty = true; } } KeyCode::Up | KeyCode::Char('k') => { if self.formula_cursor > 0 { self.formula_cursor -= 1; } } KeyCode::Down | KeyCode::Char('j') => { if self.formula_cursor + 1 < self.model.formulas.len() { self.formula_cursor += 1; } } _ => {} } Ok(()) } fn handle_category_panel_key(&mut self, key: KeyEvent) -> Result<()> { let cat_names: Vec = self.model.category_names().into_iter().map(String::from).collect(); match key.code { KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; } KeyCode::Up | KeyCode::Char('k') => { if self.cat_panel_cursor > 0 { self.cat_panel_cursor -= 1; } } KeyCode::Down | KeyCode::Char('j') => { if self.cat_panel_cursor + 1 < cat_names.len() { self.cat_panel_cursor += 1; } } KeyCode::Enter | KeyCode::Char(' ') => { if let Some(cat_name) = cat_names.get(self.cat_panel_cursor) { if let Some(view) = self.model.active_view_mut() { view.cycle_axis(cat_name); } } } // n — add a new category KeyCode::Char('n') => { self.mode = AppMode::CategoryAdd { buffer: String::new() }; } // a / o — open quick-add items mode for the selected category KeyCode::Char('a') | KeyCode::Char('o') => { if let Some(cat_name) = cat_names.get(self.cat_panel_cursor) { self.mode = AppMode::ItemAdd { category: cat_name.clone(), buffer: String::new(), }; } else { self.status_msg = "No category selected. Press n to add a category first.".to_string(); } } _ => {} } Ok(()) } fn handle_category_add_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc => { self.mode = AppMode::CategoryPanel; self.status_msg = String::new(); } KeyCode::Enter | KeyCode::Tab => { let buf = if let AppMode::CategoryAdd { buffer } = &self.mode { buffer.trim().to_string() } else { return Ok(()); }; if !buf.is_empty() { let result = command::dispatch(&mut self.model, &Command::AddCategory { name: buf.clone() }); if result.ok { // Move cursor to the new category self.cat_panel_cursor = self.model.categories.len().saturating_sub(1); let count = self.model.categories.len(); self.status_msg = format!("Added category \"{buf}\" ({count} total). Enter to add more, Esc to finish."); self.dirty = true; } else { self.status_msg = result.message.unwrap_or_default(); } } // Stay in CategoryAdd for the next entry if let AppMode::CategoryAdd { ref mut buffer } = self.mode { buffer.clear(); } } KeyCode::Char(c) => { if let AppMode::CategoryAdd { ref mut buffer } = self.mode { buffer.push(c); } } KeyCode::Backspace => { if let AppMode::CategoryAdd { ref mut buffer } = self.mode { buffer.pop(); } } _ => {} } Ok(()) } fn handle_item_add_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc => { // Return to category panel self.mode = AppMode::CategoryPanel; self.status_msg = String::new(); } KeyCode::Enter => { let (cat, buf) = if let AppMode::ItemAdd { category, buffer } = &self.mode { (category.clone(), buffer.trim().to_string()) } else { return Ok(()); }; if !buf.is_empty() { let result = command::dispatch(&mut self.model, &Command::AddItem { category: cat.clone(), item: buf.clone(), }); if result.ok { let count = self.model.category(&cat).map(|c| c.items.len()).unwrap_or(0); self.status_msg = format!("Added \"{buf}\" — {count} items. Enter to add more, Esc to finish."); self.dirty = true; } else { self.status_msg = result.message.unwrap_or_default(); } } // Clear buffer but stay in ItemAdd for next entry if let AppMode::ItemAdd { ref mut buffer, .. } = self.mode { buffer.clear(); } } KeyCode::Tab => { // Tab completes the current item and moves to next, same as Enter return self.handle_item_add_key(crossterm::event::KeyEvent::new( KeyCode::Enter, crossterm::event::KeyModifiers::NONE, )); } KeyCode::Char(c) => { if let AppMode::ItemAdd { ref mut buffer, .. } = self.mode { buffer.push(c); } } KeyCode::Backspace => { if let AppMode::ItemAdd { ref mut buffer, .. } = self.mode { buffer.pop(); } } _ => {} } Ok(()) } fn handle_view_panel_key(&mut self, key: KeyEvent) -> Result<()> { let view_names: Vec = self.model.views.keys().cloned().collect(); match key.code { KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; } KeyCode::Up | KeyCode::Char('k') => { if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; } } KeyCode::Down | KeyCode::Char('j') => { if self.view_panel_cursor + 1 < view_names.len() { self.view_panel_cursor += 1; } } KeyCode::Enter => { if let Some(name) = view_names.get(self.view_panel_cursor) { command::dispatch(&mut self.model, &Command::SwitchView { name: name.clone() }); self.mode = AppMode::Normal; } } KeyCode::Char('n') | KeyCode::Char('o') => { let new_name = format!("View {}", self.model.views.len() + 1); command::dispatch(&mut self.model, &Command::CreateView { name: new_name.clone() }); command::dispatch(&mut self.model, &Command::SwitchView { name: new_name }); self.dirty = true; self.mode = AppMode::Normal; } KeyCode::Delete | KeyCode::Char('d') => { if let Some(name) = view_names.get(self.view_panel_cursor) { command::dispatch(&mut self.model, &Command::DeleteView { name: name.clone() }); if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; } self.dirty = true; } } _ => {} } Ok(()) } fn handle_tile_select_key(&mut self, key: KeyEvent) -> Result<()> { let cat_names: Vec = self.model.category_names().into_iter().map(String::from).collect(); let cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode { cat_idx } else { 0 }; match key.code { KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; } KeyCode::Left | KeyCode::Char('h') => { if let AppMode::TileSelect { ref mut cat_idx } = self.mode { if *cat_idx > 0 { *cat_idx -= 1; } } } KeyCode::Right | KeyCode::Char('l') => { if let AppMode::TileSelect { ref mut cat_idx } = self.mode { if *cat_idx + 1 < cat_names.len() { *cat_idx += 1; } } } KeyCode::Enter | KeyCode::Char(' ') => { if let Some(name) = cat_names.get(cat_idx) { if let Some(view) = self.model.active_view_mut() { view.cycle_axis(name); } self.dirty = true; } self.mode = AppMode::Normal; } KeyCode::Char('r') => { if let Some(name) = cat_names.get(cat_idx) { command::dispatch(&mut self.model, &Command::SetAxis { category: name.clone(), axis: "row".to_string() }); self.dirty = true; } self.mode = AppMode::Normal; } KeyCode::Char('c') => { if let Some(name) = cat_names.get(cat_idx) { command::dispatch(&mut self.model, &Command::SetAxis { category: name.clone(), axis: "column".to_string() }); self.dirty = true; } self.mode = AppMode::Normal; } KeyCode::Char('p') => { if let Some(name) = cat_names.get(cat_idx) { command::dispatch(&mut self.model, &Command::SetAxis { category: name.clone(), axis: "page".to_string() }); self.dirty = true; } self.mode = AppMode::Normal; } _ => {} } Ok(()) } fn handle_export_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc => { self.mode = AppMode::Normal; } KeyCode::Enter => { let buf = if let AppMode::ExportPrompt { buffer } = &self.mode { buffer.clone() } else { return Ok(()); }; let view_name = self.model.active_view.clone(); match persistence::export_csv(&self.model, &view_name, Path::new(&buf)) { Ok(_) => { self.status_msg = format!("Exported to {buf}"); } Err(e) => { self.status_msg = format!("Export error: {e}"); } } self.mode = AppMode::Normal; } KeyCode::Char(c) => { if let AppMode::ExportPrompt { buffer } = &mut self.mode { buffer.push(c); } } KeyCode::Backspace => { if let AppMode::ExportPrompt { buffer } = &mut self.mode { buffer.pop(); } } _ => {} } Ok(()) } fn handle_wizard_key(&mut self, key: KeyEvent) -> Result<()> { if let Some(wizard) = &mut self.wizard { match &wizard.step.clone() { WizardStep::Preview => match key.code { KeyCode::Enter | KeyCode::Char(' ') => wizard.advance(), KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } _ => {} }, WizardStep::SelectArrayPath => match key.code { KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1), KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1), KeyCode::Enter => wizard.confirm_path(), KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } _ => {} }, WizardStep::ReviewProposals => match key.code { KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1), KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1), KeyCode::Char(' ') => wizard.toggle_proposal(), KeyCode::Char('c') => wizard.cycle_proposal_kind(), KeyCode::Enter => wizard.advance(), KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } _ => {} }, WizardStep::NameModel => match key.code { KeyCode::Char(c) => wizard.push_name_char(c), KeyCode::Backspace => wizard.pop_name_char(), KeyCode::Enter => { match wizard.build_model() { Ok(mut model) => { model.normalize_view_state(); self.model = model; self.formula_cursor = 0; self.dirty = true; self.status_msg = "Import successful! Press :w to save.".to_string(); self.mode = AppMode::Normal; self.wizard = None; } Err(e) => { if let Some(w) = &mut self.wizard { w.message = Some(format!("Error: {e}")); } } } } KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } _ => {} }, WizardStep::Done => { self.mode = AppMode::Normal; self.wizard = None; } } } Ok(()) } // ── Motion helpers ─────────────────────────────────────────────────────── fn move_selection(&mut self, dr: i32, dc: i32) { let (row_max, col_max) = { let view = match self.model.active_view() { Some(v) => v, None => return }; let layout = GridLayout::new(&self.model, view); (layout.row_count().saturating_sub(1), layout.col_count().saturating_sub(1)) }; if let Some(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 = { let view = match self.model.active_view() { Some(v) => v, None => return }; GridLayout::new(&self.model, view).row_count().saturating_sub(1) }; if let Some(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 = { let view = match self.model.active_view() { Some(v) => v, None => return }; GridLayout::new(&self.model, view).col_count().saturating_sub(1) }; if let Some(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 = { let view = match self.model.active_view() { Some(v) => v, None => return }; GridLayout::new(&self.model, view).row_count().saturating_sub(1) }; if let Some(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 = match self.model.active_view() { Some(v) => v, None => return, }; 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 val = self.model.evaluate(&key); let s = match &val { CellValue::Number(n) => format!("{n}"), CellValue::Text(t) => t.clone(), CellValue::Empty => 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; if let Some(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() .map(|v| v.categories_on(Axis::Page).into_iter().map(String::from).collect()) .unwrap_or_default(); 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() .and_then(|v| v.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; } } if let Some(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; } } if let Some(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 { let view = self.model.active_view()?; let (sel_row, sel_col) = view.selected; GridLayout::new(&self.model, view).cell_key(sel_row, sel_col) } // ── Persistence ────────────────────────────────────────────────────────── pub fn save(&mut self) -> Result<()> { if let Some(path) = &self.file_path.clone() { persistence::save(&self.model, path)?; self.dirty = false; self.status_msg = format!("Saved to {}", path.display()); } else { self.status_msg = "No file path — use :w to save.".to_string(); } Ok(()) } pub fn autosave_if_needed(&mut self) { if self.dirty && self.last_autosave.elapsed() > Duration::from_secs(30) { if let Some(path) = &self.file_path.clone() { let ap = persistence::autosave_path(path); let _ = persistence::save(&self.model, &ap); self.last_autosave = Instant::now(); } } } pub fn start_import_wizard(&mut self, json: serde_json::Value) { self.wizard = Some(ImportWizard::new(json)); self.mode = AppMode::ImportWizard; } /// Hint text for the status bar (context-sensitive) pub fn hint_text(&self) -> &'static str { match &self.mode { AppMode::Normal => "hjkl:nav i:edit x:clear /:search F/C/V:panels T:tiles [:]:page ::cmd", AppMode::Editing { .. } => "Enter:commit Esc:cancel", AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back", AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression", AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items Esc:back", AppMode::CategoryAdd { .. } => "Enter:add & continue Tab:same Esc:done — type a category name", AppMode::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name", AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back", AppMode::TileSelect { .. } => "hl:select Enter:cycle r/c/p:set-axis Esc:back", AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :help", AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel", _ => "", } } }