diff --git a/src/command/cmd.rs b/src/command/cmd.rs index c273b2c..34beedd 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -2228,7 +2228,28 @@ effect_cmd!( "help", |_args: &[String]| -> Result<(), String> { Ok(()) }, |_args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![effect::change_mode(AppMode::Help)] + vec![ + effect::help_page_set(0), + effect::change_mode(AppMode::Help), + ] + } +); + +effect_cmd!( + HelpPageNextCmd, + "help-page-next", + |_args: &[String]| -> Result<(), String> { Ok(()) }, + |_args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![effect::help_page_next()] + } +); + +effect_cmd!( + HelpPagePrevCmd, + "help-page-prev", + |_args: &[String]| -> Result<(), String> { Ok(()) }, + |_args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![effect::help_page_prev()] } ); @@ -2320,6 +2341,16 @@ pub fn default_registry() -> CmdRegistry { r.register_pure(&ExportCmd(vec![]), ExportCmd::parse); r.register_pure(&WriteCmd(vec![]), WriteCmd::parse); r.register_pure(&HelpCmd(vec![]), HelpCmd::parse); + r.register( + &HelpPageNextCmd(vec![]), + HelpPageNextCmd::parse, + |_args, _ctx| Ok(Box::new(HelpPageNextCmd(vec![]))), + ); + r.register( + &HelpPagePrevCmd(vec![]), + HelpPagePrevCmd::parse, + |_args, _ctx| Ok(Box::new(HelpPagePrevCmd(vec![]))), + ); // ── Navigation (unified Move) ────────────────────────────────────── r.register( diff --git a/src/ui/app.rs b/src/ui/app.rs index 31217ff..0da2b36 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -181,6 +181,8 @@ pub struct App { /// when filters would change. Pending edits are stored alongside and /// applied to the model on commit/navigate-away. pub drill_state: Option, + /// 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, @@ -223,6 +225,7 @@ impl App { 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(), @@ -310,9 +313,14 @@ impl App { self.rebuild_layout(); } - /// True when the model has no categories yet (show welcome screen) + /// 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 { - self.model.categories.is_empty() + 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<()> { @@ -372,6 +380,7 @@ impl App { 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", _ => "", } } @@ -688,4 +697,220 @@ mod tests { .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 + ); + } } diff --git a/src/ui/effect.rs b/src/ui/effect.rs index 514cb85..0aa827c 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -892,3 +892,42 @@ pub fn change_mode(mode: AppMode) -> Box { pub fn set_selected(row: usize, col: usize) -> Box { Box::new(SetSelected(row, col)) } + +// ── Help page navigation ──────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct HelpPageNext; +impl Effect for HelpPageNext { + fn apply(&self, app: &mut App) { + let max = crate::ui::help::HELP_PAGE_COUNT.saturating_sub(1); + app.help_page = app.help_page.saturating_add(1).min(max); + } +} + +#[derive(Debug)] +pub struct HelpPagePrev; +impl Effect for HelpPagePrev { + fn apply(&self, app: &mut App) { + app.help_page = app.help_page.saturating_sub(1); + } +} + +#[derive(Debug)] +pub struct HelpPageSet(pub usize); +impl Effect for HelpPageSet { + fn apply(&self, app: &mut App) { + app.help_page = self.0; + } +} + +pub fn help_page_next() -> Box { + Box::new(HelpPageNext) +} + +pub fn help_page_prev() -> Box { + Box::new(HelpPagePrev) +} + +pub fn help_page_set(page: usize) -> Box { + Box::new(HelpPageSet(page)) +}