- app.rs: scroll_rows (Ctrl+D/U) now clamps to the cross-product row count and follows the viewport, matching move_selection's behaviour. Previously it could push selected past the last row, causing selected_cell_key to return None and silently ignoring edits. - model.rs: add normalize_view_state() which resets row/col offsets to zero on all views. - main.rs, dispatch.rs, app.rs: call normalize_view_state() after every model replacement (initial load, :Load command, wizard import) so stale offsets from a previous session can't hide the grid. - app.rs: clamp formula_cursor to the current formula list length at the top of handle_formula_panel_key so a model reload with fewer formulas can't leave the cursor pointing past the end. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1235 lines
55 KiB
Rust
1235 lines
55 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<()> {
|
|
// 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 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(mut model) => {
|
|
model.normalize_view_state();
|
|
self.model = model;
|
|
self.formula_cursor = 0;
|
|
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) {
|
|
let row_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();
|
|
cross_product_strs(&row_cats, &self.model, view).len().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 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
|
|
}
|