Files
improvise/crates/improvise-command/src/command/cmd/wizard.rs
T

308 lines
11 KiB
Rust

//! Import-wizard key handling: which key means what depends on the step, and that
//! decision is pure, so it lives here (improvise-2wm, gotcha 22).
use crate::command::key::KeyCode;
use crate::import::wizard::{ImportWizard, WizardStep};
use crate::ui::effect::{self, Effect, WizardOp};
use super::core::{Cmd, CmdContext};
/// Dispatch the current key to the import wizard.
#[derive(Debug)]
pub struct HandleWizardKey;
impl Cmd for HandleWizardKey {
fn name(&self) -> &'static str {
"handle-wizard-key"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
ctx.wizard
.map(|wizard| step_effects(wizard, ctx.key_code))
.unwrap_or_default()
}
}
fn step_effects(wizard: &ImportWizard, key: KeyCode) -> Vec<Box<dyn Effect>> {
match wizard.step {
WizardStep::Preview => preview_keys(key),
WizardStep::SelectArrayPath => select_path_keys(key),
WizardStep::ReviewProposals => review_proposal_keys(key),
WizardStep::ConfigureDates => configure_date_keys(key),
WizardStep::ResolveDuplicates => resolve_duplicate_keys(key),
WizardStep::NameDuplicateCategory => name_duplicate_category_keys(key),
WizardStep::DefineFormulas if wizard.formula_editing => formula_edit_keys(key),
WizardStep::DefineFormulas => formula_list_keys(key),
WizardStep::NameModel => name_model_keys(key),
WizardStep::Done => done_keys(key),
}
}
// ── Per-step key tables ──────────────────────────────────────────────────────
fn preview_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
match key {
KeyCode::Enter | KeyCode::Char(' ') => op(WizardOp::Advance),
KeyCode::Esc => close(),
_ => back_key(key),
}
}
fn select_path_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
match key {
KeyCode::Enter => op(WizardOp::ConfirmPath),
_ => cursor_keys(key),
}
}
fn review_proposal_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
match key {
KeyCode::Char(' ') => op(WizardOp::ToggleProposal),
KeyCode::Char('c') => op(WizardOp::CycleProposalKind),
KeyCode::Enter => op(WizardOp::Advance),
_ => cursor_keys(key),
}
}
fn configure_date_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
match key {
KeyCode::Char(' ') => op(WizardOp::ToggleDateComponent),
KeyCode::Char('s') => op(WizardOp::ToggleDateShape),
KeyCode::Enter => op(WizardOp::Advance),
_ => cursor_keys(key),
}
}
/// Up/Down choose an option; PageUp/PageDown scroll the colliding records.
fn resolve_duplicate_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
match key {
KeyCode::Enter => op(WizardOp::ChooseDuplicate),
_ => scroll_keys(key).unwrap_or_else(|| cursor_keys(key)),
}
}
fn name_duplicate_category_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
match key {
KeyCode::Enter => op(WizardOp::Advance),
KeyCode::Backspace => op(WizardOp::PopDuplicateNameChar),
KeyCode::Char(c) => op(WizardOp::PushDuplicateNameChar(c)),
KeyCode::Esc => close(),
_ => back_key(key),
}
}
fn formula_list_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
match key {
KeyCode::Char('n') => op(WizardOp::StartFormulaEdit),
KeyCode::Char('d') => op(WizardOp::DeleteFormula),
KeyCode::Enter => op(WizardOp::Advance),
_ => cursor_keys(key),
}
}
fn formula_edit_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
match key {
KeyCode::Enter => op(WizardOp::ConfirmFormula),
KeyCode::Esc => op(WizardOp::CancelFormulaEdit),
KeyCode::Backspace => op(WizardOp::PopFormulaChar),
KeyCode::Char(c) => op(WizardOp::PushFormulaChar(c)),
_ => ignored(),
}
}
fn name_model_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
match key {
KeyCode::Enter => op(WizardOp::Build),
KeyCode::Backspace => op(WizardOp::PopNameChar),
KeyCode::Char(c) => op(WizardOp::PushNameChar(c)),
KeyCode::Esc => close(),
_ => back_key(key),
}
}
/// The confirmation step: Enter installs the built workbook, and both Esc and Left
/// return to `NameModel` — Esc here does not discard the import (improvise-907).
fn done_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
match key {
KeyCode::Enter => vec![Box::new(effect::AcceptWizardModel)],
KeyCode::Esc => op(WizardOp::Back),
KeyCode::Down | KeyCode::Char('j') => op(WizardOp::Scroll(1)),
KeyCode::Up | KeyCode::Char('k') => op(WizardOp::Scroll(-1)),
_ => scroll_keys(key).unwrap_or_else(|| back_key(key)),
}
}
/// PageUp/PageDown move a step's scrolling list by one item.
fn scroll_keys(key: KeyCode) -> Option<Vec<Box<dyn Effect>>> {
match key {
KeyCode::PageDown => Some(op(WizardOp::Scroll(1))),
KeyCode::PageUp => Some(op(WizardOp::Scroll(-1))),
_ => None,
}
}
/// The list-navigation keys every browsing step shares.
fn cursor_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
match key {
KeyCode::Up | KeyCode::Char('k') => op(WizardOp::MoveCursor(-1)),
KeyCode::Down | KeyCode::Char('j') => op(WizardOp::MoveCursor(1)),
KeyCode::Esc => close(),
_ => back_key(key),
}
}
/// Left steps back on every step. It is the one key free everywhere: Backspace edits
/// buffers and `h` is a printable the name step needs (improvise-907).
fn back_key(key: KeyCode) -> Vec<Box<dyn Effect>> {
match key {
KeyCode::Left => op(WizardOp::Back),
_ => ignored(),
}
}
// ── Effect constructors ──────────────────────────────────────────────────────
fn op(op: WizardOp) -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::WizardApply(op))]
}
fn close() -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::CloseWizard)]
}
fn ignored() -> Vec<Box<dyn Effect>> {
vec![]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::import::wizard::ImportWizard;
fn wizard_at(step: WizardStep) -> ImportWizard {
let mut w = ImportWizard::new(serde_json::json!([
{"region": "East", "revenue": 1.0},
{"region": "West", "revenue": 2.0},
]));
w.step = step;
w
}
fn ops(wizard: &ImportWizard, key: KeyCode) -> String {
format!("{:?}", step_effects(wizard, key))
}
/// Every browsing step shares j/k/arrow navigation and Esc-to-close, so
/// the shared `cursor_keys` fallback must reach all of them.
#[test]
fn browsing_steps_share_cursor_and_close_keys() {
for step in [
WizardStep::SelectArrayPath,
WizardStep::ReviewProposals,
WizardStep::ConfigureDates,
WizardStep::DefineFormulas,
] {
let w = wizard_at(step.clone());
assert!(
ops(&w, KeyCode::Char('j')).contains("MoveCursor(1)"),
"{step:?} should move down on j"
);
assert!(
ops(&w, KeyCode::Up).contains("MoveCursor(-1)"),
"{step:?} should move up on Up"
);
assert!(
ops(&w, KeyCode::Esc).contains("CloseWizard"),
"{step:?} should close on Esc"
);
}
}
/// While typing a formula, Esc backs out of the edit only — it must not
/// close the wizard — and printable keys go into the formula buffer.
#[test]
fn formula_editing_shadows_the_browsing_keys() {
let mut w = wizard_at(WizardStep::DefineFormulas);
w.formula_editing = true;
assert!(ops(&w, KeyCode::Esc).contains("CancelFormulaEdit"));
assert!(ops(&w, KeyCode::Char('j')).contains("PushFormulaChar('j')"));
assert!(ops(&w, KeyCode::Enter).contains("ConfirmFormula"));
}
/// Enter on the name step builds the workbook rather than advancing the step
/// machine; every other printable key edits the name.
#[test]
fn name_model_enter_builds_the_model() {
let w = wizard_at(WizardStep::NameModel);
assert!(ops(&w, KeyCode::Enter).contains("Build"));
assert!(ops(&w, KeyCode::Char('j')).contains("PushNameChar('j')"));
assert!(ops(&w, KeyCode::Backspace).contains("PopNameChar"));
}
/// Left steps back on every step, including the two text-entry ones —
/// there must be no step from which the only exit is Esc (improvise-907).
#[test]
fn every_step_binds_left_as_back() {
for step in [
WizardStep::Preview,
WizardStep::SelectArrayPath,
WizardStep::ReviewProposals,
WizardStep::ConfigureDates,
WizardStep::ResolveDuplicates,
WizardStep::NameDuplicateCategory,
WizardStep::DefineFormulas,
WizardStep::NameModel,
WizardStep::Done,
] {
let w = wizard_at(step.clone());
assert!(
ops(&w, KeyCode::Left).contains("Back"),
"{step:?} should step back on Left"
);
}
}
/// On the confirmation step Enter installs the built workbook and Esc steps back;
/// no key closes the wizard outright (improvise-907, from improvise-c6j).
#[test]
fn done_confirms_on_enter_and_steps_back_on_esc() {
let w = wizard_at(WizardStep::Done);
assert!(ops(&w, KeyCode::Enter).contains("AcceptWizardModel"));
assert!(ops(&w, KeyCode::Esc).contains("Back"));
assert_eq!(step_effects(&w, KeyCode::Char('z')).len(), 0);
}
/// improvise-cgqt: Enter takes the option under the cursor, never a bare
/// advance, which would skip the question; the page keys scroll the list.
#[test]
fn the_duplicates_step_chooses_on_enter_and_scrolls_on_the_page_keys() {
let w = wizard_at(WizardStep::ResolveDuplicates);
assert_eq!(ops(&w, KeyCode::Enter), "[WizardApply(ChooseDuplicate)]");
assert!(ops(&w, KeyCode::Down).contains("MoveCursor(1)"));
assert!(ops(&w, KeyCode::PageDown).contains("Scroll(1)"));
assert!(ops(&w, KeyCode::PageUp).contains("Scroll(-1)"));
assert!(ops(&w, KeyCode::Esc).contains("CloseWizard"));
}
/// The name step is text entry: `j` is a letter here, not a cursor key.
#[test]
fn the_category_name_step_types_into_the_name() {
let w = wizard_at(WizardStep::NameDuplicateCategory);
assert!(ops(&w, KeyCode::Char('j')).contains("PushDuplicateNameChar('j')"));
assert!(ops(&w, KeyCode::Backspace).contains("PopDuplicateNameChar"));
assert!(ops(&w, KeyCode::Enter).contains("Advance"));
}
/// The confirm step's warnings scroll, so a long list is never cut off.
#[test]
fn done_scrolls_its_warnings() {
let w = wizard_at(WizardStep::Done);
assert!(ops(&w, KeyCode::Down).contains("Scroll(1)"));
assert!(ops(&w, KeyCode::PageUp).contains("Scroll(-1)"));
}
#[test]
fn unbound_keys_emit_nothing() {
let w = wizard_at(WizardStep::ReviewProposals);
assert_eq!(step_effects(&w, KeyCode::Char('Z')).len(), 0);
}
}