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)
This commit is contained in:
Edward Langley
2026-04-08 22:27:36 -07:00
parent 33676b8abd
commit bbc009b088
3 changed files with 298 additions and 3 deletions

View File

@ -2228,7 +2228,28 @@ effect_cmd!(
"help", "help",
|_args: &[String]| -> Result<(), String> { Ok(()) }, |_args: &[String]| -> Result<(), String> { Ok(()) },
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> { |_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![effect::help_page_next()]
}
);
effect_cmd!(
HelpPagePrevCmd,
"help-page-prev",
|_args: &[String]| -> Result<(), String> { Ok(()) },
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![effect::help_page_prev()]
} }
); );
@ -2320,6 +2341,16 @@ pub fn default_registry() -> CmdRegistry {
r.register_pure(&ExportCmd(vec![]), ExportCmd::parse); r.register_pure(&ExportCmd(vec![]), ExportCmd::parse);
r.register_pure(&WriteCmd(vec![]), WriteCmd::parse); r.register_pure(&WriteCmd(vec![]), WriteCmd::parse);
r.register_pure(&HelpCmd(vec![]), HelpCmd::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) ────────────────────────────────────── // ── Navigation (unified Move) ──────────────────────────────────────
r.register( r.register(

View File

@ -181,6 +181,8 @@ pub struct App {
/// when filters would change. Pending edits are stored alongside and /// when filters would change. Pending edits are stored alongside and
/// applied to the model on commit/navigate-away. /// applied to the model on commit/navigate-away.
pub drill_state: Option<DrillState>, 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). /// Terminal dimensions (updated on resize and at startup).
pub term_width: u16, pub term_width: u16,
pub term_height: u16, pub term_height: u16,
@ -223,6 +225,7 @@ impl App {
view_back_stack: Vec::new(), view_back_stack: Vec::new(),
view_forward_stack: Vec::new(), view_forward_stack: Vec::new(),
drill_state: None, drill_state: None,
help_page: 0,
term_width: crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80), term_width: crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80),
term_height: crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24), term_height: crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24),
expanded_cats: std::collections::HashSet::new(), expanded_cats: std::collections::HashSet::new(),
@ -310,9 +313,14 @@ impl App {
self.rebuild_layout(); 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 { 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<()> { 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::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::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :show-item :help",
AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel", 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(); .unwrap();
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("")); 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
);
}
} }

View File

@ -892,3 +892,42 @@ pub fn change_mode(mode: AppMode) -> Box<dyn Effect> {
pub fn set_selected(row: usize, col: usize) -> Box<dyn Effect> { pub fn set_selected(row: usize, col: usize) -> Box<dyn Effect> {
Box::new(SetSelected(row, col)) 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<dyn Effect> {
Box::new(HelpPageNext)
}
pub fn help_page_prev() -> Box<dyn Effect> {
Box::new(HelpPagePrev)
}
pub fn help_page_set(page: usize) -> Box<dyn Effect> {
Box::new(HelpPageSet(page))
}