//! 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> { ctx.wizard .map(|wizard| step_effects(wizard, ctx.key_code)) .unwrap_or_default() } } fn step_effects(wizard: &ImportWizard, key: KeyCode) -> Vec> { 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> { match key { KeyCode::Enter | KeyCode::Char(' ') => op(WizardOp::Advance), KeyCode::Esc => close(), _ => back_key(key), } } fn select_path_keys(key: KeyCode) -> Vec> { match key { KeyCode::Enter => op(WizardOp::ConfirmPath), _ => cursor_keys(key), } } fn review_proposal_keys(key: KeyCode) -> Vec> { 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> { 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> { match key { KeyCode::Enter => op(WizardOp::ChooseDuplicate), _ => scroll_keys(key).unwrap_or_else(|| cursor_keys(key)), } } fn name_duplicate_category_keys(key: KeyCode) -> Vec> { 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> { 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> { 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> { 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> { 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>> { 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> { 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> { match key { KeyCode::Left => op(WizardOp::Back), _ => ignored(), } } // ── Effect constructors ────────────────────────────────────────────────────── fn op(op: WizardOp) -> Vec> { vec![Box::new(effect::WizardApply(op))] } fn close() -> Vec> { vec![Box::new(effect::CloseWizard)] } fn ignored() -> Vec> { 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); } }