195f3b44b8
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
308 lines
11 KiB
Rust
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);
|
|
}
|
|
}
|