Files
improvise/src/ui/app.rs
Edward Langley bbc009b088 feat(command): implement help page navigation
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)
2026-04-11 00:06:49 -07:00

917 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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