Implement help page navigation with `help-page-next` and `help-page-prev` commands. - Added `HelpPageNextCmd` and `HelpPagePrevCmd` to `src/command/cmd.rs` . - Registered help navigation commands in `CmdRegistry` . - Updated `HelpCmd` to initialize the help page. - Added unit tests for help navigation in `src/ui/app.rs` . Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
917 lines
32 KiB
Rust
917 lines
32 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 std::rc::Rc;
|
||
|
||
use ratatui::style::Color;
|
||
|
||
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::ui::grid::{
|
||
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
|
||
};
|
||
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 (Rc for cheap cloning).
|
||
pub records: Rc<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>,
|
||
}
|
||
|
||
/// Display configuration for the bottom-bar minibuffer.
|
||
/// Carried structurally by text-entry `AppMode` variants.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct MinibufferConfig {
|
||
pub buffer_key: &'static str,
|
||
pub prompt: String,
|
||
pub color: Color,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum AppMode {
|
||
Normal,
|
||
Editing {
|
||
minibuf: MinibufferConfig,
|
||
},
|
||
FormulaEdit {
|
||
minibuf: MinibufferConfig,
|
||
},
|
||
FormulaPanel,
|
||
CategoryPanel,
|
||
/// Quick-add a new category: Enter adds and stays open, Esc closes.
|
||
CategoryAdd {
|
||
minibuf: MinibufferConfig,
|
||
},
|
||
/// Quick-add items to `category`: Enter adds and stays open, Esc closes.
|
||
ItemAdd {
|
||
category: String,
|
||
minibuf: MinibufferConfig,
|
||
},
|
||
ViewPanel,
|
||
TileSelect,
|
||
ImportWizard,
|
||
ExportPrompt {
|
||
minibuf: MinibufferConfig,
|
||
},
|
||
/// Vim-style `:` command line
|
||
CommandMode {
|
||
minibuf: MinibufferConfig,
|
||
},
|
||
Help,
|
||
Quit,
|
||
}
|
||
|
||
impl AppMode {
|
||
/// Extract the minibuffer config from text-entry modes, if present.
|
||
pub fn minibuffer(&self) -> Option<&MinibufferConfig> {
|
||
match self {
|
||
Self::Editing { minibuf, .. }
|
||
| Self::FormulaEdit { minibuf, .. }
|
||
| Self::CommandMode { minibuf, .. }
|
||
| Self::CategoryAdd { minibuf, .. }
|
||
| Self::ItemAdd { minibuf, .. }
|
||
| Self::ExportPrompt { minibuf, .. } => Some(minibuf),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub fn editing() -> Self {
|
||
Self::Editing {
|
||
minibuf: MinibufferConfig {
|
||
buffer_key: "edit",
|
||
prompt: "edit: ".into(),
|
||
color: Color::Green,
|
||
},
|
||
}
|
||
}
|
||
|
||
pub fn formula_edit() -> Self {
|
||
Self::FormulaEdit {
|
||
minibuf: MinibufferConfig {
|
||
buffer_key: "formula",
|
||
prompt: "formula: ".into(),
|
||
color: Color::Cyan,
|
||
},
|
||
}
|
||
}
|
||
|
||
pub fn command_mode() -> Self {
|
||
Self::CommandMode {
|
||
minibuf: MinibufferConfig {
|
||
buffer_key: "command",
|
||
prompt: ":".into(),
|
||
color: Color::Yellow,
|
||
},
|
||
}
|
||
}
|
||
|
||
pub fn category_add() -> Self {
|
||
Self::CategoryAdd {
|
||
minibuf: MinibufferConfig {
|
||
buffer_key: "category",
|
||
prompt: "new category: ".into(),
|
||
color: Color::Yellow,
|
||
},
|
||
}
|
||
}
|
||
|
||
pub fn item_add(category: String) -> Self {
|
||
let prompt = format!("add item to {category}: ");
|
||
Self::ItemAdd {
|
||
category,
|
||
minibuf: MinibufferConfig {
|
||
buffer_key: "item",
|
||
prompt,
|
||
color: Color::Green,
|
||
},
|
||
}
|
||
}
|
||
|
||
pub fn export_prompt() -> Self {
|
||
Self::ExportPrompt {
|
||
minibuf: MinibufferConfig {
|
||
buffer_key: "export",
|
||
prompt: "export path: ".into(),
|
||
color: Color::Yellow,
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
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>,
|
||
/// Current page index in the Help screen (0-based).
|
||
pub help_page: usize,
|
||
/// 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>>,
|
||
/// Current grid layout, derived from model + view + drill_state.
|
||
/// Rebuilt via `rebuild_layout()` after state changes.
|
||
pub layout: GridLayout,
|
||
keymap_set: KeymapSet,
|
||
}
|
||
|
||
impl App {
|
||
pub fn new(model: Model, file_path: Option<PathBuf>) -> Self {
|
||
let layout = {
|
||
let view = model.active_view();
|
||
GridLayout::with_frozen_records(&model, view, None)
|
||
};
|
||
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,
|
||
help_page: 0,
|
||
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,
|
||
layout,
|
||
keymap_set: KeymapSet::default_keymaps(),
|
||
}
|
||
}
|
||
|
||
/// Rebuild the grid layout from current model, view, and drill state.
|
||
/// Note: `with_frozen_records` already handles pruning internally.
|
||
pub fn rebuild_layout(&mut self) {
|
||
let view = self.model.active_view();
|
||
let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records));
|
||
self.layout = GridLayout::with_frozen_records(&self.model, view, frozen);
|
||
}
|
||
|
||
pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> {
|
||
let view = self.model.active_view();
|
||
let layout = &self.layout;
|
||
let (sel_row, sel_col) = view.selected;
|
||
CmdContext {
|
||
model: &self.model,
|
||
layout,
|
||
registry: self.keymap_set.registry(),
|
||
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,
|
||
view_back_stack: &self.view_back_stack,
|
||
view_forward_stack: &self.view_forward_stack,
|
||
display_value: {
|
||
let key = layout.cell_key(sel_row, sel_col);
|
||
if let Some(k) = &key {
|
||
if let Some((idx, dim)) = crate::view::synthetic_record_info(k) {
|
||
self.drill_state
|
||
.as_ref()
|
||
.and_then(|s| s.pending_edits.get(&(idx, dim)).cloned())
|
||
.or_else(|| layout.resolve_display(k))
|
||
.unwrap_or_default()
|
||
} else {
|
||
self.model
|
||
.get_cell(k)
|
||
.map(|v| v.to_string())
|
||
.unwrap_or_default()
|
||
}
|
||
} else {
|
||
String::new()
|
||
}
|
||
},
|
||
visible_rows: (self.term_height as usize).saturating_sub(8),
|
||
visible_cols: {
|
||
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
|
||
let col_widths = compute_col_widths(&self.model, layout, fmt_comma, fmt_decimals);
|
||
let row_header_width = compute_row_header_width(layout);
|
||
compute_visible_cols(
|
||
&col_widths,
|
||
row_header_width,
|
||
self.term_width,
|
||
view.col_offset,
|
||
)
|
||
},
|
||
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);
|
||
}
|
||
self.rebuild_layout();
|
||
}
|
||
|
||
/// True when the model has no user-defined categories (show welcome/help).
|
||
/// Virtual categories (_Index, _Dim) are always present and don't count.
|
||
pub fn is_empty_model(&self) -> bool {
|
||
use crate::model::category::CategoryKind;
|
||
self.model
|
||
.categories
|
||
.values()
|
||
.all(|c| matches!(c.kind, CategoryKind::VirtualIndex | CategoryKind::VirtualDim))
|
||
}
|
||
|
||
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
|
||
self.rebuild_layout();
|
||
|
||
// 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 Tab:commit+right 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",
|
||
AppMode::Help => "h/l:pages q/Esc:close",
|
||
_ => "",
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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 col_offset_scrolls_when_cursor_moves_past_visible_columns() {
|
||
use crate::model::cell::{CellKey, CellValue};
|
||
// Create a model with 8 wide columns. Column item names are 30 chars
|
||
// each → column widths ~31 chars. With term_width=80, row header ~4,
|
||
// data area ~76 → only ~2 columns actually fit. But the rough estimate
|
||
// (80−30)/12 = 4 over-counts, so viewport_effects never scrolls.
|
||
let mut m = Model::new("T");
|
||
m.add_category("Row").unwrap();
|
||
m.add_category("Col").unwrap();
|
||
m.category_mut("Row").unwrap().add_item("R1");
|
||
for i in 0..8 {
|
||
let name = format!("VeryLongColumnItemName_{i:03}");
|
||
m.category_mut("Col").unwrap().add_item(&name);
|
||
}
|
||
// Populate a value so the model isn't empty
|
||
let key = CellKey::new(vec![
|
||
("Row".to_string(), "R1".to_string()),
|
||
("Col".to_string(), "VeryLongColumnItemName_000".to_string()),
|
||
]);
|
||
m.set_cell(key, CellValue::Number(1.0));
|
||
|
||
let mut app = App::new(m, None);
|
||
app.term_width = 80;
|
||
|
||
// Press 'l' (right) 3 times to move cursor to column 3.
|
||
// Only ~2 columns fit in 76 chars of data area (each col ~26 chars wide),
|
||
// so column 3 is well off-screen. The buggy estimate (80−30)/12 = 4
|
||
// thinks 4 columns fit, so it won't scroll until col 4.
|
||
for _ in 0..3 {
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
}
|
||
|
||
assert_eq!(
|
||
app.model.active_view().selected.1,
|
||
3,
|
||
"cursor should be at column 3"
|
||
);
|
||
assert!(
|
||
app.model.active_view().col_offset > 0,
|
||
"col_offset should scroll when cursor moves past visible area (only ~2 cols fit \
|
||
in 80-char terminal with 26-char-wide columns), but col_offset is {}",
|
||
app.model.active_view().col_offset
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn home_jumps_to_first_col() {
|
||
let mut app = two_col_model();
|
||
app.model.active_view_mut().selected = (1, 1);
|
||
app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert_eq!(app.model.active_view().selected, (1, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn end_jumps_to_last_col() {
|
||
let mut app = two_col_model();
|
||
app.model.active_view_mut().selected = (1, 0);
|
||
app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert_eq!(app.model.active_view().selected, (1, 1));
|
||
}
|
||
|
||
#[test]
|
||
fn page_down_scrolls_by_three_quarters_visible() {
|
||
let mut app = two_col_model();
|
||
// Add enough rows
|
||
for i in 0..30 {
|
||
app.model
|
||
.category_mut("Row")
|
||
.unwrap()
|
||
.add_item(&format!("R{i}"));
|
||
}
|
||
app.term_height = 28; // ~20 visible rows → delta = 15
|
||
app.model.active_view_mut().selected = (0, 0);
|
||
app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert_eq!(app.model.active_view().selected.1, 0, "column preserved");
|
||
assert!(
|
||
app.model.active_view().selected.0 > 0,
|
||
"row should advance on PageDown"
|
||
);
|
||
// 3/4 of ~20 = 15
|
||
assert_eq!(app.model.active_view().selected.0, 15);
|
||
}
|
||
|
||
#[test]
|
||
fn page_up_scrolls_backward() {
|
||
let mut app = two_col_model();
|
||
for i in 0..30 {
|
||
app.model
|
||
.category_mut("Row")
|
||
.unwrap()
|
||
.add_item(&format!("R{i}"));
|
||
}
|
||
app.term_height = 28;
|
||
app.model.active_view_mut().selected = (20, 0);
|
||
app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert_eq!(app.model.active_view().selected.0, 5);
|
||
}
|
||
|
||
#[test]
|
||
fn jump_last_row_scrolls_with_small_terminal() {
|
||
let mut app = two_col_model();
|
||
// Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12.
|
||
for i in 0..10 {
|
||
app.model
|
||
.category_mut("Row")
|
||
.unwrap()
|
||
.add_item(&format!("R{i}"));
|
||
}
|
||
app.term_height = 13; // ~5 visible rows
|
||
app.model.active_view_mut().selected = (0, 0);
|
||
// G jumps to last row (row 12)
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
let last = app.model.active_view().selected.0;
|
||
assert_eq!(last, 12, "should be at last row");
|
||
// With only ~5 visible rows and 13 rows, offset should scroll.
|
||
// Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll.
|
||
let offset = app.model.active_view().row_offset;
|
||
assert!(
|
||
offset > 0,
|
||
"row_offset should scroll when last row is beyond visible area, but is {offset}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_d_scrolls_viewport_with_small_terminal() {
|
||
let mut app = two_col_model();
|
||
for i in 0..30 {
|
||
app.model
|
||
.category_mut("Row")
|
||
.unwrap()
|
||
.add_item(&format!("R{i}"));
|
||
}
|
||
app.term_height = 13; // ~5 visible rows
|
||
app.model.active_view_mut().selected = (0, 0);
|
||
// Ctrl+d scrolls by 5 rows
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
||
.unwrap();
|
||
assert_eq!(app.model.active_view().selected.0, 5);
|
||
// Press Ctrl+d again — now at row 10 with only 5 visible rows,
|
||
// row_offset should have scrolled (not stay at 0 due to hardcoded 20)
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
||
.unwrap();
|
||
assert_eq!(app.model.active_view().selected.0, 10);
|
||
assert!(
|
||
app.model.active_view().row_offset > 0,
|
||
"row_offset should scroll with small terminal, but is {}",
|
||
app.model.active_view().row_offset
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn tab_in_edit_mode_commits_and_moves_right() {
|
||
let mut app = two_col_model();
|
||
app.model.active_view_mut().selected = (0, 0);
|
||
// Enter edit mode
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert!(matches!(app.mode, AppMode::Editing { .. }));
|
||
// Type a digit
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
// Press Tab — should commit, move right, re-enter edit mode
|
||
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
||
.unwrap();
|
||
// Should be in edit mode on column 1
|
||
assert!(
|
||
matches!(app.mode, AppMode::Editing { .. }),
|
||
"should be in edit mode after Tab, but mode is {:?}",
|
||
app.mode
|
||
);
|
||
assert_eq!(
|
||
app.model.active_view().selected.1,
|
||
1,
|
||
"should have moved to column 1"
|
||
);
|
||
}
|
||
|
||
#[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(""));
|
||
}
|
||
|
||
// ── is_empty_model ──────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn fresh_model_is_empty() {
|
||
let app = App::new(Model::new("T"), None);
|
||
assert!(
|
||
app.is_empty_model(),
|
||
"a brand-new model with only virtual categories should be empty"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn model_with_user_category_is_not_empty() {
|
||
let mut m = Model::new("T");
|
||
m.add_category("Sales").unwrap();
|
||
let app = App::new(m, None);
|
||
assert!(
|
||
!app.is_empty_model(),
|
||
"a model with a user-defined category should not be empty"
|
||
);
|
||
}
|
||
|
||
// ── Help mode navigation ────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn help_page_next_advances_page() {
|
||
let mut app = App::new(Model::new("T"), None);
|
||
app.mode = AppMode::Help;
|
||
app.help_page = 0;
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert_eq!(app.help_page, 1, "l should advance to page 1");
|
||
}
|
||
|
||
#[test]
|
||
fn help_page_prev_goes_back() {
|
||
let mut app = App::new(Model::new("T"), None);
|
||
app.mode = AppMode::Help;
|
||
app.help_page = 2;
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert_eq!(app.help_page, 1, "h should go back to page 1");
|
||
}
|
||
|
||
#[test]
|
||
fn help_page_clamps_at_zero() {
|
||
let mut app = App::new(Model::new("T"), None);
|
||
app.mode = AppMode::Help;
|
||
app.help_page = 0;
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert_eq!(app.help_page, 0, "page should not go below 0");
|
||
}
|
||
|
||
#[test]
|
||
fn help_page_clamps_at_max() {
|
||
use crate::ui::help::HELP_PAGE_COUNT;
|
||
|
||
let mut app = App::new(Model::new("T"), None);
|
||
app.mode = AppMode::Help;
|
||
app.help_page = HELP_PAGE_COUNT - 1;
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert_eq!(
|
||
app.help_page,
|
||
HELP_PAGE_COUNT - 1,
|
||
"page should not exceed the last page"
|
||
);
|
||
}
|
||
|
||
// ── Help mode exits ─────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn help_q_returns_to_normal() {
|
||
let mut app = App::new(Model::new("T"), None);
|
||
app.mode = AppMode::Help;
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert!(
|
||
matches!(app.mode, AppMode::Normal),
|
||
"q should return to Normal mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn help_esc_returns_to_normal() {
|
||
let mut app = App::new(Model::new("T"), None);
|
||
app.mode = AppMode::Help;
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert!(
|
||
matches!(app.mode, AppMode::Normal),
|
||
"Esc should return to Normal mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn help_colon_enters_command_mode() {
|
||
let mut app = App::new(Model::new("T"), None);
|
||
app.mode = AppMode::Help;
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert!(
|
||
matches!(app.mode, AppMode::CommandMode { .. }),
|
||
"colon in Help mode should enter CommandMode, got {:?}",
|
||
app.mode
|
||
);
|
||
}
|
||
|
||
// ── Effect error feedback ───────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn add_item_to_nonexistent_category_sets_status() {
|
||
use crate::ui::effect::Effect;
|
||
let mut app = App::new(Model::new("T"), None);
|
||
let effect = crate::ui::effect::AddItem {
|
||
category: "Nonexistent".to_string(),
|
||
item: "x".to_string(),
|
||
};
|
||
effect.apply(&mut app);
|
||
assert!(
|
||
app.status_msg.contains("Unknown category"),
|
||
"should report unknown category, got: {:?}",
|
||
app.status_msg
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn add_formula_with_bad_syntax_sets_status() {
|
||
use crate::ui::effect::Effect;
|
||
let mut app = App::new(Model::new("T"), None);
|
||
let effect = crate::ui::effect::AddFormula {
|
||
raw: "!!!invalid".to_string(),
|
||
target_category: "X".to_string(),
|
||
};
|
||
effect.apply(&mut app);
|
||
assert!(
|
||
app.status_msg.contains("Formula error"),
|
||
"should report formula error, got: {:?}",
|
||
app.status_msg
|
||
);
|
||
}
|
||
|
||
// ── Tile select stays in mode ───────────────────────────────────────
|
||
|
||
#[test]
|
||
fn tile_axis_change_stays_in_tile_select() {
|
||
let mut app = two_col_model();
|
||
app.mode = AppMode::TileSelect;
|
||
app.tile_cat_idx = 0;
|
||
|
||
// Press 'r' to set axis to Row — should stay in TileSelect
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert!(
|
||
matches!(app.mode, AppMode::TileSelect),
|
||
"should stay in TileSelect after axis change, got {:?}",
|
||
app.mode
|
||
);
|
||
assert!(
|
||
!app.status_msg.is_empty(),
|
||
"should show status feedback after axis change"
|
||
);
|
||
}
|
||
|
||
// ── Panel colon bindings ────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn category_panel_colon_enters_command_mode() {
|
||
let mut app = two_col_model();
|
||
app.mode = AppMode::CategoryPanel;
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert!(
|
||
matches!(app.mode, AppMode::CommandMode { .. }),
|
||
"colon in CategoryPanel should enter CommandMode, got {:?}",
|
||
app.mode
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn view_panel_colon_enters_command_mode() {
|
||
let mut app = two_col_model();
|
||
app.mode = AppMode::ViewPanel;
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert!(
|
||
matches!(app.mode, AppMode::CommandMode { .. }),
|
||
"colon in ViewPanel should enter CommandMode, got {:?}",
|
||
app.mode
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn tile_select_colon_enters_command_mode() {
|
||
let mut app = two_col_model();
|
||
app.mode = AppMode::TileSelect;
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert!(
|
||
matches!(app.mode, AppMode::CommandMode { .. }),
|
||
"colon in TileSelect should enter CommandMode, got {:?}",
|
||
app.mode
|
||
);
|
||
}
|
||
}
|