Files
improvise/src/ui/app.rs
Ed L 6ba7245338 fix: page navigation works with multiple page-axis categories
[/] previously broke after the first page category due to a hard-coded
`break`. Replaced with odometer-style navigation: ] advances the last
page category, carrying into the previous when it wraps (like digit
incrementing). [ decrements the same way. Single-category behaviour is
unchanged except it now wraps around instead of clamping at the end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:02:01 -07:00

1221 lines
54 KiB
Rust

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;
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<PathBuf>,
pub mode: AppMode,
pub status_msg: String,
pub wizard: Option<ImportWizard>,
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<char>,
/// Yanked cell value for `p` paste
pub yanked: Option<CellValue>,
}
impl App {
pub fn new(model: Model, file_path: Option<PathBuf>) -> 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;
}
}
// ── 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 };
}
}
// Legacy Ctrl+Arrow still works
(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 };
}
}
// ── 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 <path.json>".to_string();
} else {
match std::fs::read_to_string(rest) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&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 <name>".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 <category> <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 <category> <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 <category> 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 <category> 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 <target_cat> <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 <category> <Name = expr>".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 <format> e.g. ",.2" ",.0" ".2"
// "," = comma separators; ".N" = N decimal places
if rest.is_empty() {
self.status_msg = "Usage: :set-format <fmt> 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::<f64>() {
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<()> {
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 target = self.model.formulas[self.formula_cursor].target.clone();
command::dispatch(&mut self.model, &Command::RemoveFormula { target });
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<String> = 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<String> = 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<String> = 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(model) => {
self.model = model;
self.dirty = true;
self.status_msg = "Import successful! Press :w <path> 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) {
// Use cross-product counts so multi-category axes navigate correctly.
let (row_max, col_max) = {
let view = match self.model.active_view() { Some(v) => v, None => return };
let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
let rm = cross_product_strs(&row_cats, &self.model, view).len().saturating_sub(1);
let cm = cross_product_strs(&col_cats, &self.model, view).len().saturating_sub(1);
(rm, cm)
};
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 };
let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
cross_product_strs(&row_cats, &self.model, view).len().saturating_sub(1)
};
if let Some(view) = self.model.active_view_mut() {
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 };
let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
cross_product_strs(&col_cats, &self.model, view).len().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) {
if let Some(view) = self.model.active_view_mut() {
let new_r = (view.selected.0 as i32 + delta).max(0) as usize;
view.selected.0 = new_r;
}
}
/// 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 row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
let page_cats: Vec<String> = view.categories_on(Axis::Page).into_iter().map(String::from).collect();
let (cur_row, cur_col) = view.selected;
// Build cross-product for rows and cols (inline, avoids circular dep with grid.rs)
let row_items: Vec<Vec<String>> = cross_product_strs(&row_cats, &self.model, view);
let col_items: Vec<Vec<String>> = cross_product_strs(&col_cats, &self.model, view);
let page_coords: Vec<(String, String)> = page_cats.iter().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();
let sel = view.page_selection(cat)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_default();
(cat.clone(), sel)
}).collect();
// Enumerate all (row_idx, col_idx) grid positions
let total_rows = row_items.len().max(1);
let total_cols = col_items.len().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 row_item = row_items.get(ri).cloned().unwrap_or_default();
let col_item = col_items.get(ci).cloned().unwrap_or_default();
let mut coords = page_coords.clone();
for (cat, item) in row_cats.iter().zip(row_item.iter()) {
coords.push((cat.clone(), item.clone()));
}
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
coords.push((cat.clone(), item.clone()));
}
let key = CellKey::new(coords);
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<String>, usize)> {
let page_cats: Vec<String> = 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<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()
.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<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; }
}
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<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;
}
}
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<CellKey> {
let view = self.model.active_view()?;
let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
let page_cats: Vec<String> = view.categories_on(Axis::Page).into_iter().map(String::from).collect();
let (sel_row, sel_col) = view.selected;
let mut coords = vec![];
for cat_name in &page_cats {
let items = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect::<Vec<_>>())
.unwrap_or_default();
let sel = view.page_selection(cat_name)
.map(String::from)
.or_else(|| items.first().cloned())?;
coords.push((cat_name.clone(), sel));
}
// Use cross-product indexing so multi-category axes resolve correctly.
let row_items = cross_product_strs(&row_cats, &self.model, view);
let row_item = row_items.get(sel_row)?;
for (cat, item) in row_cats.iter().zip(row_item.iter()) {
coords.push((cat.clone(), item.clone()));
}
let col_items = cross_product_strs(&col_cats, &self.model, view);
let col_item = col_items.get(sel_col)?;
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
coords.push((cat.clone(), item.clone()));
}
Some(CellKey::new(coords))
}
// ── 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 <path> 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",
_ => "",
}
}
}
/// Compute the Cartesian product of items from `cats` in the active view,
/// filtering hidden items. Returns `vec![vec![]]` when `cats` is empty.
fn cross_product_strs(cats: &[String], model: &crate::model::Model, view: &crate::view::View) -> Vec<Vec<String>> {
if cats.is_empty() {
return vec![vec![]];
}
let mut result: Vec<Vec<String>> = vec![vec![]];
for cat_name in cats {
let items: Vec<String> = model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter()
.filter(|item| !view.is_hidden(cat_name, item))
.map(String::from).collect())
.unwrap_or_default();
result = result.into_iter().flat_map(|prefix| {
items.iter().map(move |item| {
let mut row = prefix.clone();
row.push(item.clone());
row
})
}).collect();
}
result
}