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:
229
src/ui/app.rs
229
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<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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user