Files
improvise/src/ui/app.rs
Edward Langley 334597d825 refactor: update TogglePanelAndFocus to use open/focused flags
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)
2026-04-06 15:09:58 -07:00

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(""));
}
}