Initial implementation of Improvise TUI
Multi-dimensional data modeling terminal application with:
- Core data model: categories, items, groups, sparse cell store
- Formula system: recursive-descent parser, named formulas, WHERE clauses
- View system: Row/Column/Page axes, tile-based pivot, page slicing
- JSON import wizard (interactive TUI + headless auto-mode)
- Command layer: all mutations via typed Command enum for headless replay
- TUI: Ratatui grid, tile bar, formula/category/view panels, help overlay
- Persistence: .improv (JSON), .improv.gz (gzip), CSV export, autosave
- Static binary via x86_64-unknown-linux-musl + nix flake devShell
- Headless mode: --cmd '{"op":"..."}' and --script file.jsonl
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
657
src/ui/app.rs
Normal file
657
src/ui/app.rs
Normal file
@ -0,0 +1,657 @@
|
||||
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::formula::parse_formula;
|
||||
use crate::import::wizard::{ImportWizard, WizardState};
|
||||
use crate::persistence;
|
||||
use crate::view::Axis;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AppMode {
|
||||
Normal,
|
||||
Editing { buffer: String },
|
||||
FormulaEdit { buffer: String },
|
||||
FormulaPanel,
|
||||
CategoryPanel,
|
||||
ViewPanel,
|
||||
TileSelect { cat_idx: usize },
|
||||
ImportWizard,
|
||||
ExportPrompt { 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,
|
||||
/// Input buffer for command-line style input
|
||||
pub input_buffer: String,
|
||||
/// Search query
|
||||
pub search_query: String,
|
||||
pub search_mode: bool,
|
||||
pub formula_panel_open: bool,
|
||||
pub category_panel_open: bool,
|
||||
pub view_panel_open: bool,
|
||||
/// Category panel cursor
|
||||
pub cat_panel_cursor: usize,
|
||||
/// View panel cursor
|
||||
pub view_panel_cursor: usize,
|
||||
/// Formula panel cursor
|
||||
pub formula_cursor: usize,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
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(),
|
||||
input_buffer: String::new(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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 { buffer } => {
|
||||
self.handle_edit_key(key)?;
|
||||
}
|
||||
AppMode::FormulaEdit { buffer } => {
|
||||
self.handle_formula_edit_key(key)?;
|
||||
}
|
||||
AppMode::FormulaPanel => {
|
||||
self.handle_formula_panel_key(key)?;
|
||||
}
|
||||
AppMode::CategoryPanel => {
|
||||
self.handle_category_panel_key(key)?;
|
||||
}
|
||||
AppMode::ViewPanel => {
|
||||
self.handle_view_panel_key(key)?;
|
||||
}
|
||||
AppMode::TileSelect { cat_idx } => {
|
||||
self.handle_tile_select_key(key)?;
|
||||
}
|
||||
AppMode::ExportPrompt { buffer } => {
|
||||
self.handle_export_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);
|
||||
}
|
||||
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char('q'), KeyModifiers::CONTROL) => {
|
||||
self.mode = AppMode::Quit;
|
||||
}
|
||||
(KeyCode::F(1), _) => {
|
||||
self.mode = AppMode::Help;
|
||||
}
|
||||
(KeyCode::Char('s'), KeyModifiers::CONTROL) => {
|
||||
self.save()?;
|
||||
}
|
||||
(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() };
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
(KeyCode::Enter, _) => {
|
||||
let cell_key = self.selected_cell_key();
|
||||
let current = cell_key.as_ref().map(|k| {
|
||||
self.model.get_cell(k).to_string()
|
||||
}).unwrap_or_default();
|
||||
self.mode = AppMode::Editing { buffer: current };
|
||||
}
|
||||
(KeyCode::Char('/'), _) => {
|
||||
self.search_mode = true;
|
||||
self.search_query.clear();
|
||||
}
|
||||
// Tab cycles 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 with Ctrl+Arrow
|
||||
(KeyCode::Left, KeyModifiers::CONTROL) | (KeyCode::Right, KeyModifiers::CONTROL)
|
||||
| (KeyCode::Up, KeyModifiers::CONTROL) | (KeyCode::Down, KeyModifiers::CONTROL) => {
|
||||
let cat_names: Vec<String> = self.model.category_names()
|
||||
.into_iter().map(String::from).collect();
|
||||
if !cat_names.is_empty() {
|
||||
self.mode = AppMode::TileSelect { cat_idx: 0 };
|
||||
}
|
||||
}
|
||||
// Page axis navigation with [ ]
|
||||
(KeyCode::Char('['), _) => {
|
||||
self.page_prev();
|
||||
}
|
||||
(KeyCode::Char(']'), _) => {
|
||||
self.page_next();
|
||||
}
|
||||
// Formula panel shortcut
|
||||
(KeyCode::Char('F'), KeyModifiers::NONE) => {
|
||||
self.formula_panel_open = true;
|
||||
self.mode = AppMode::FormulaPanel;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_search_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Esc => { self.search_mode = false; }
|
||||
KeyCode::Enter => { self.search_mode = false; }
|
||||
KeyCode::Char(c) => { self.search_query.push(c); }
|
||||
KeyCode::Backspace => { self.search_query.pop(); }
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_edit_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
let buf = if let AppMode::Editing { buffer } = &self.mode {
|
||||
buffer.clone()
|
||||
} else { return Ok(()); };
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Commit value
|
||||
if let Some(key) = self.selected_cell_key() {
|
||||
let value = if buf.is_empty() {
|
||||
CellValue::Empty
|
||||
} else if let Ok(n) = buf.parse::<f64>() {
|
||||
CellValue::Number(n)
|
||||
} else {
|
||||
CellValue::Text(buf.clone())
|
||||
};
|
||||
self.model.set_cell(key, value);
|
||||
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(())
|
||||
}
|
||||
|
||||
fn handle_formula_edit_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
let buf = if let AppMode::FormulaEdit { buffer } = &self.mode {
|
||||
buffer.clone()
|
||||
} else { return Ok(()); };
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => { self.mode = AppMode::FormulaPanel; }
|
||||
KeyCode::Enter => {
|
||||
// Try to parse and add formula
|
||||
let first_cat = self.model.category_names().into_iter().next().map(String::from);
|
||||
if let Some(cat) = first_cat {
|
||||
match parse_formula(&buf, &cat) {
|
||||
Ok(formula) => {
|
||||
self.model.add_formula(formula);
|
||||
self.status_msg = "Formula added".to_string();
|
||||
self.dirty = true;
|
||||
}
|
||||
Err(e) => {
|
||||
self.status_msg = format!("Formula error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
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') => {
|
||||
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();
|
||||
self.model.remove_formula(&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(' ') => {
|
||||
// Cycle axis for selected category
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
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) {
|
||||
let _ = self.model.switch_view(name);
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
let new_name = format!("View {}", self.model.views.len() + 1);
|
||||
self.model.create_view(&new_name);
|
||||
let _ = self.model.switch_view(&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) {
|
||||
let _ = self.model.delete_view(name);
|
||||
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 => { 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) {
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.set_axis(name, Axis::Row);
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
if let Some(name) = cat_names.get(cat_idx) {
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.set_axis(name, Axis::Column);
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
if let Some(name) = cat_names.get(cat_idx) {
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.set_axis(name, Axis::Page);
|
||||
}
|
||||
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 path = PathBuf::from(buf);
|
||||
let view_name = self.model.active_view.clone();
|
||||
match persistence::export_csv(&self.model, &view_name, &path) {
|
||||
Ok(_) => { self.status_msg = format!("Exported to {}", path.display()); }
|
||||
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.state.clone() {
|
||||
WizardState::Preview => {
|
||||
match key.code {
|
||||
KeyCode::Enter | KeyCode::Char(' ') => wizard.advance(),
|
||||
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
WizardState::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; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
WizardState::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; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
WizardState::NameModel => {
|
||||
match key.code {
|
||||
KeyCode::Char(c) => wizard.push_name_char(c),
|
||||
KeyCode::Backspace => wizard.pop_name_char(),
|
||||
KeyCode::Enter => {
|
||||
let result = wizard.build_model();
|
||||
match result {
|
||||
Ok(model) => {
|
||||
self.model = model;
|
||||
self.dirty = true;
|
||||
self.status_msg = "Import successful!".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; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
WizardState::Done => {
|
||||
self.mode = AppMode::Normal;
|
||||
self.wizard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_selection(&mut self, dr: i32, dc: i32) {
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
let (r, c) = view.selected;
|
||||
let new_r = (r as i32 + dr).max(0) as usize;
|
||||
let new_c = (c as i32 + dc).max(0) as usize;
|
||||
view.selected = (new_r, new_c);
|
||||
}
|
||||
}
|
||||
|
||||
fn page_next(&mut self) {
|
||||
let page_cats: Vec<String> = self.model.active_view()
|
||||
.map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
for cat_name in &page_cats {
|
||||
let items: Vec<String> = self.model.category(cat_name)
|
||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
if items.is_empty() { continue; }
|
||||
|
||||
let current = self.model.active_view()
|
||||
.and_then(|v| v.page_selection(cat_name))
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| items[0].clone());
|
||||
|
||||
let idx = items.iter().position(|i| i == ¤t).unwrap_or(0);
|
||||
let next_idx = (idx + 1).min(items.len() - 1);
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.set_page_selection(cat_name, &items[next_idx]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
fn page_prev(&mut self) {
|
||||
let page_cats: Vec<String> = self.model.active_view()
|
||||
.map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
for cat_name in &page_cats {
|
||||
let items: Vec<String> = self.model.category(cat_name)
|
||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
if items.is_empty() { continue; }
|
||||
|
||||
let current = self.model.active_view()
|
||||
.and_then(|v| v.page_selection(cat_name))
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| items[0].clone());
|
||||
|
||||
let idx = items.iter().position(|i| i == ¤t).unwrap_or(0);
|
||||
let prev_idx = idx.saturating_sub(1);
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.set_page_selection(cat_name, &items[prev_idx]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_cell_key(&self) -> Option<CellKey> {
|
||||
let view = self.model.active_view()?;
|
||||
let row_cats: Vec<&str> = view.categories_on(Axis::Row);
|
||||
let col_cats: Vec<&str> = view.categories_on(Axis::Column);
|
||||
let page_cats: Vec<&str> = view.categories_on(Axis::Page);
|
||||
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
let mut coords = vec![];
|
||||
|
||||
// Page coords
|
||||
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.to_string(), sel));
|
||||
}
|
||||
|
||||
// Row coords
|
||||
for (i, cat_name) in row_cats.iter().enumerate() {
|
||||
let items: Vec<String> = self.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();
|
||||
let item = items.get(sel_row)?.clone();
|
||||
coords.push((cat_name.to_string(), item));
|
||||
}
|
||||
|
||||
// Col coords
|
||||
for (i, cat_name) in col_cats.iter().enumerate() {
|
||||
let items: Vec<String> = self.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();
|
||||
let item = items.get(sel_col)?.clone();
|
||||
coords.push((cat_name.to_string(), item));
|
||||
}
|
||||
|
||||
Some(CellKey::new(coords))
|
||||
}
|
||||
|
||||
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 set. Use Ctrl+E to export.".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 autosave_path = persistence::autosave_path(path);
|
||||
let _ = persistence::save(&self.model, &autosave_path);
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user