Skip to content

Why escape means back

The rule

Wizard::run handles Ok(StepOutcome::Back) and Err(InquireError::OperationCanceled) in the same match arm. inquire returns OperationCanceled when the user presses Esc, so escaping a prompt and a step deciding to go back are literally indistinguishable to the driver.

On the first step, that arm returns WizardError::Cancelled rather than moving the index to −1.

Why escape is not a cancel

The alternative — Esc aborts the whole wizard — is what most prompt libraries do on their own, and it is wrong for a multi-step form.

A wizard exists because there are several things to ask. By the fourth question the user has invested four answers, and the most common reason they reach for Esc is that they got the third one wrong. Under an abort-on-escape rule that keystroke throws away all four and starts them again, and the only way to change an answer is to finish the wizard and re-run it.

Mapping Esc to "back" makes the obvious keystroke do the thing people are actually trying to do. It costs one behaviour: there is no single key that abandons the whole wizard from the middle. Ctrl+C covers that, and it arrives as a distinct error variant so the caller can treat it differently.

Why steps do not have to know about it

A step implementation never sees OperationCanceled. It calls an inquire prompt, ?-propagates whatever comes back, and the driver does the interpretation:

state.name = Some(inquire::Text::new("Name?").prompt()?);
Ok(StepOutcome::Next)

If each step had to match on the error and decide what escape meant, one of them would eventually decide differently, and the wizard would behave inconsistently from step to step. Putting the interpretation in one place in the driver means the rule cannot drift.

The cost is that a step cannot opt out. There is no way to write a step where Esc means something else, because the driver has already consumed the signal by the time the step's caller sees it. A step that needs an escape-like affordance has to offer it as a prompt option instead — an explicit "go back" choice in a Select, say, which returns StepOutcome::Back.

Why the first step cancels instead of ignoring the key

There is no step before the first one, so the driver has three options: ignore the keystroke, re-run the first step, or end the wizard.

Ignoring it is the worst of the three. The user presses a key, nothing happens, and they cannot tell whether the program is stuck. Re-running the first step is nearly as bad — it looks like the wizard reset itself, and it discards whatever they had typed into it.

Ending the wizard is the one that matches the mental model. The user is at the start; pressing Esc at the start means "actually, no". WizardError::Cancelled says exactly that, and it is a different variant from Interrupted so a caller can print "nothing was written" for one and stay quiet for the other.

What this costs you as an implementer

Back navigation re-runs the previous step against the current state, not against a snapshot. The wizard keeps no history and rolls nothing back.

That makes steps that append rather than assign dangerous. A step doing state.tags.push(tag) will push a second tag when the user comes back to it; a step doing state.tag = Some(tag) will not. The rule is that a step must be safe to run more than once, and the crate cannot enforce it — it is a contract on your implementations.

The upside of the same design is that pre-filling a prompt from the current state gives the user their previous answer to edit, for free, because the value is still sitting in the state when the step re-runs.