Update TogglePanelAndFocus and related components to use open/focused flags. Changed TogglePanelAndFocus from currently_open to open+focused flags. Parser accepts optional [open] [focused] arguments. Interactive mode toggles between open+focus and closed/unfocused. Keymap updates: F/C/V in panel modes close panels when focused. Model initialization: Virtual categories _Index/_Dim default to None axis, regular categories auto-assign Row/Column/Page. App test context updated with visible_rows/visible_cols. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
393 lines
15 KiB
Rust
393 lines
15 KiB
Rust
use anyhow::Result;
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use crate::command::cmd::CmdContext;
|
|
use crate::command::keymap::{Keymap, KeymapSet};
|
|
use crate::import::wizard::ImportWizard;
|
|
use crate::model::cell::CellValue;
|
|
use crate::model::Model;
|
|
use crate::persistence;
|
|
use crate::view::GridLayout;
|
|
|
|
/// Drill-down state: frozen record snapshot + pending edits that have not
|
|
/// yet been applied to the model.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct DrillState {
|
|
/// Frozen snapshot of records shown in the drill view.
|
|
pub records: Vec<(
|
|
crate::model::cell::CellKey,
|
|
crate::model::cell::CellValue,
|
|
)>,
|
|
/// Pending edits keyed by (record_idx, column_name) → new string value.
|
|
/// column_name is either "Value" or a category name.
|
|
pub pending_edits: std::collections::HashMap<(usize, String), String>,
|
|
}
|
|
|
|
#[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,
|
|
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,
|
|
/// Yanked cell value for `p` paste
|
|
pub yanked: Option<CellValue>,
|
|
/// Tile select cursor (which category index is highlighted)
|
|
pub tile_cat_idx: usize,
|
|
/// View navigation history: views visited before the current one.
|
|
/// Pushed on SwitchView, popped by `<` (back).
|
|
pub view_back_stack: Vec<String>,
|
|
/// Views that were "back-ed" from, available for forward navigation (`>`).
|
|
pub view_forward_stack: Vec<String>,
|
|
/// Frozen records list for the drill view. When present, this is the
|
|
/// snapshot that records-mode layouts iterate — records don't disappear
|
|
/// when filters would change. Pending edits are stored alongside and
|
|
/// applied to the model on commit/navigate-away.
|
|
pub drill_state: Option<DrillState>,
|
|
/// Terminal dimensions (updated on resize and at startup).
|
|
pub term_width: u16,
|
|
pub term_height: u16,
|
|
/// Categories expanded in the category panel tree view.
|
|
pub expanded_cats: std::collections::HashSet<String>,
|
|
/// Named text buffers for text-entry modes
|
|
pub buffers: HashMap<String, String>,
|
|
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
|
|
pub transient_keymap: Option<Arc<Keymap>>,
|
|
keymap_set: KeymapSet,
|
|
}
|
|
|
|
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,
|
|
yanked: None,
|
|
tile_cat_idx: 0,
|
|
view_back_stack: Vec::new(),
|
|
view_forward_stack: Vec::new(),
|
|
drill_state: None,
|
|
term_width: crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80),
|
|
term_height: crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24),
|
|
expanded_cats: std::collections::HashSet::new(),
|
|
buffers: HashMap::new(),
|
|
transient_keymap: None,
|
|
keymap_set: KeymapSet::default_keymaps(),
|
|
}
|
|
}
|
|
|
|
pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> {
|
|
let view = self.model.active_view();
|
|
let frozen_records = self.drill_state.as_ref().map(|s| s.records.clone());
|
|
let layout = GridLayout::with_frozen_records(&self.model, view, frozen_records);
|
|
let (sel_row, sel_col) = view.selected;
|
|
CmdContext {
|
|
model: &self.model,
|
|
mode: &self.mode,
|
|
selected: view.selected,
|
|
row_offset: view.row_offset,
|
|
col_offset: view.col_offset,
|
|
search_query: &self.search_query,
|
|
yanked: &self.yanked,
|
|
dirty: self.dirty,
|
|
search_mode: self.search_mode,
|
|
formula_panel_open: self.formula_panel_open,
|
|
category_panel_open: self.category_panel_open,
|
|
view_panel_open: self.view_panel_open,
|
|
buffers: &self.buffers,
|
|
formula_cursor: self.formula_cursor,
|
|
cat_panel_cursor: self.cat_panel_cursor,
|
|
view_panel_cursor: self.view_panel_cursor,
|
|
tile_cat_idx: self.tile_cat_idx,
|
|
cell_key: layout.cell_key(sel_row, sel_col),
|
|
row_count: layout.row_count(),
|
|
col_count: layout.col_count(),
|
|
none_cats: layout.none_cats.clone(),
|
|
view_back_stack: self.view_back_stack.clone(),
|
|
view_forward_stack: self.view_forward_stack.clone(),
|
|
records_col: if layout.is_records_mode() {
|
|
Some(layout.col_label(sel_col))
|
|
} else {
|
|
None
|
|
},
|
|
records_value: if layout.is_records_mode() {
|
|
// Check pending edits first, then fall back to original
|
|
let col_name = layout.col_label(sel_col);
|
|
let pending = self.drill_state.as_ref().and_then(|s| {
|
|
s.pending_edits.get(&(sel_row, col_name.clone())).cloned()
|
|
});
|
|
pending.or_else(|| layout.records_display(sel_row, sel_col))
|
|
} else {
|
|
None
|
|
},
|
|
// Approximate visible rows/cols from terminal size.
|
|
// Chrome: title(1) + border(2) + col_headers(n_col_levels) + separator(1)
|
|
// + tile_bar(1) + status_bar(1) = ~8 rows of chrome.
|
|
visible_rows: (self.term_height as usize).saturating_sub(8),
|
|
// Visible cols depends on column widths — use a rough estimate.
|
|
// The grid renderer does the precise calculation.
|
|
visible_cols: ((self.term_width as usize).saturating_sub(30) / 12).max(1),
|
|
expanded_cats: &self.expanded_cats,
|
|
key_code: key,
|
|
}
|
|
}
|
|
|
|
pub fn apply_effects(&mut self, effects: Vec<Box<dyn super::effect::Effect>>) {
|
|
for effect in effects {
|
|
effect.apply(self);
|
|
}
|
|
}
|
|
|
|
/// 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<()> {
|
|
// Transient keymap (prefix key sequence) takes priority
|
|
if let Some(transient) = self.transient_keymap.take() {
|
|
let effects = {
|
|
let ctx = self.cmd_context(key.code, key.modifiers);
|
|
self.keymap_set
|
|
.dispatch_transient(&transient, &ctx, key.code, key.modifiers)
|
|
};
|
|
if let Some(effects) = effects {
|
|
self.apply_effects(effects);
|
|
}
|
|
// Whether matched or not, transient is consumed
|
|
return Ok(());
|
|
}
|
|
|
|
// Try mode keymap — if a binding matches, apply effects and return
|
|
let effects = {
|
|
let ctx = self.cmd_context(key.code, key.modifiers);
|
|
self.keymap_set.dispatch(&ctx, key.code, key.modifiers)
|
|
};
|
|
if let Some(effects) = effects {
|
|
self.apply_effects(effects);
|
|
}
|
|
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 R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::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 d:delete 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/n:set-axis Esc:back",
|
|
AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :show-item :help",
|
|
AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
|
|
_ => "",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::model::Model;
|
|
|
|
fn two_col_model() -> App {
|
|
let mut m = Model::new("T");
|
|
m.add_category("Row").unwrap(); // → Row axis
|
|
m.add_category("Col").unwrap(); // → Column axis
|
|
m.category_mut("Row").unwrap().add_item("A");
|
|
m.category_mut("Row").unwrap().add_item("B");
|
|
m.category_mut("Row").unwrap().add_item("C");
|
|
m.category_mut("Col").unwrap().add_item("X");
|
|
m.category_mut("Col").unwrap().add_item("Y");
|
|
App::new(m, None)
|
|
}
|
|
|
|
fn run_cmd(app: &mut App, cmd: &dyn crate::command::cmd::Cmd) {
|
|
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
|
let effects = cmd.execute(&ctx);
|
|
drop(ctx);
|
|
app.apply_effects(effects);
|
|
}
|
|
|
|
fn enter_advance_cmd(app: &App) -> crate::command::cmd::EnterAdvance {
|
|
use crate::command::cmd::CursorState;
|
|
let view = app.model.active_view();
|
|
let cursor = CursorState {
|
|
row: view.selected.0,
|
|
col: view.selected.1,
|
|
row_count: 3,
|
|
col_count: 2,
|
|
row_offset: 0,
|
|
col_offset: 0,
|
|
visible_rows: 20,
|
|
visible_cols: 8,
|
|
};
|
|
crate::command::cmd::EnterAdvance { cursor }
|
|
}
|
|
|
|
#[test]
|
|
fn enter_advance_moves_down_within_column() {
|
|
let mut app = two_col_model();
|
|
app.model.active_view_mut().selected = (0, 0);
|
|
let cmd = enter_advance_cmd(&app);
|
|
run_cmd(&mut app, &cmd);
|
|
assert_eq!(app.model.active_view().selected, (1, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn enter_advance_wraps_to_top_of_next_column() {
|
|
let mut app = two_col_model();
|
|
// row_max = 2 (A,B,C), col 0 → should wrap to (0, 1)
|
|
app.model.active_view_mut().selected = (2, 0);
|
|
let cmd = enter_advance_cmd(&app);
|
|
run_cmd(&mut app, &cmd);
|
|
assert_eq!(app.model.active_view().selected, (0, 1));
|
|
}
|
|
|
|
#[test]
|
|
fn enter_advance_stays_at_bottom_right() {
|
|
let mut app = two_col_model();
|
|
app.model.active_view_mut().selected = (2, 1);
|
|
let cmd = enter_advance_cmd(&app);
|
|
run_cmd(&mut app, &cmd);
|
|
assert_eq!(app.model.active_view().selected, (2, 1));
|
|
}
|
|
|
|
#[test]
|
|
fn import_command_switches_to_import_wizard_mode() {
|
|
// Regression: execute_command was resetting mode to Normal after
|
|
// :import set it to ImportWizard, so the wizard never appeared.
|
|
let mut app = two_col_model();
|
|
let json: serde_json::Value = serde_json::json!([{"cat": "A", "val": 1}]);
|
|
app.start_import_wizard(json);
|
|
assert!(
|
|
matches!(app.mode, AppMode::ImportWizard),
|
|
"mode should be ImportWizard after start_import_wizard"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn execute_import_command_leaves_mode_as_import_wizard() {
|
|
let mut app = two_col_model();
|
|
// Inject JSON via start_import_wizard to simulate what :import does
|
|
app.start_import_wizard(serde_json::json!([{"x": 1}]));
|
|
// After the command the mode must NOT be reset to Normal
|
|
assert!(
|
|
!matches!(app.mode, AppMode::Normal),
|
|
"mode must not be Normal after import wizard is opened"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn command_mode_typing_appends_to_buffer() {
|
|
use crossterm::event::KeyEvent;
|
|
let mut app = two_col_model();
|
|
// Enter command mode with ':'
|
|
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
|
.unwrap();
|
|
assert!(matches!(app.mode, AppMode::CommandMode { .. }));
|
|
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some(""));
|
|
|
|
// Type 'q'
|
|
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
|
|
.unwrap();
|
|
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("q"));
|
|
}
|
|
|
|
#[test]
|
|
fn command_mode_buffer_cleared_on_reentry() {
|
|
use crossterm::event::KeyEvent;
|
|
let mut app = two_col_model();
|
|
// Enter command mode, type something, escape
|
|
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
|
.unwrap();
|
|
app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
|
|
.unwrap();
|
|
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("x"));
|
|
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
|
.unwrap();
|
|
|
|
// Re-enter command mode — buffer should be cleared
|
|
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
|
.unwrap();
|
|
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some(""));
|
|
}
|
|
}
|