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:
@ -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(
|
||||||
|
|||||||
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
|
/// 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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user