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

@ -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<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,
@ -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
);
}
}

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> {
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))
}