Build a three-step wizard¶
By the end of this you'll have a small command-line program that asks three questions, lets you press Esc to go back and change an answer, shows a progress line while it works, and prints the result as either a text table or JSON.
Allow about twenty minutes. Everything is compiled locally; the first build pulls down roughly a hundred crates and takes a few minutes, and after that it's seconds.
Before you start¶
You'll need:
- A Rust toolchain. The crate's manifest declares 1.82 as its minimum, and any recent stable will do.
- A real terminal. The prompts need a TTY, so run this in a terminal window — piping the program's input from a file or a here-doc will not work.
You don't need rust-tool-base or any other part of the toolkit.
rtb-tui stands alone.
Create the project¶
Then add the dependencies:
$ cargo add rtb-tui inquire async-trait
$ cargo add serde --features derive
$ cargo add tabled --features derive
$ cargo add tokio --features macros,rt-multi-thread,time
Five of those need a word of explanation.
inquire is separate because rtb-tui gives you the wizard driver,
not the prompts — you write the prompts yourself, and they come from
inquire. Keep the version aligned with the one rtb-tui depends on;
two different inquire versions in one graph means two incompatible
InquireError types and a baffling error at your first ?.
async-trait is needed because WizardStep::prompt is an async method
on a trait, and tokio because Wizard::run is async and something
has to drive it. rtb-tui doesn't choose a runtime for you, so this is
your choice to make.
serde and tabled are for the output at the end.
Define the state the wizard fills in¶
The wizard owns one value and hands each step a &mut to it. Put this
at the top of src/main.rs:
#[derive(Default)]
struct Profile {
name: Option<String>,
language: Option<String>,
as_json: bool,
}
Option for the two answers rather than String, so a half-finished
profile is representable. That matters in a moment, when a step
pre-fills its prompt from whatever is already there.
Write the first step¶
A step is a type implementing WizardStep. It issues a prompt, writes
the answer into the state, and says where to go next:
use async_trait::async_trait;
use rtb_tui::{InquireError, StepOutcome, WizardStep};
struct AskName;
#[async_trait]
impl WizardStep<Profile> for AskName {
fn name(&self) -> &'static str {
"project-name"
}
async fn prompt(&self, state: &mut Profile) -> Result<StepOutcome, InquireError> {
let mut question = inquire::Text::new("Project name?");
if let Some(current) = state.name.as_deref() {
question = question.with_initial_value(current);
}
state.name = Some(question.prompt()?);
Ok(StepOutcome::Next)
}
}
Three things are happening there.
name returns a stable identifier. It never reaches the user during
the wizard — it appears in the error if this step fails — so name it
after what it asks for, not "step1".
The if let is what makes back-navigation feel right. When the user
comes back to this step, state.name still holds their previous
answer, and with_initial_value puts it in the box ready to edit
rather than making them retype it.
The ? on question.prompt() propagates whatever inquire returns,
including the cancellation it produces when the user presses
Esc. You don't handle that here — the wizard does.
Add the two remaining steps¶
struct AskLanguage;
#[async_trait]
impl WizardStep<Profile> for AskLanguage {
fn name(&self) -> &'static str {
"language"
}
async fn prompt(&self, state: &mut Profile) -> Result<StepOutcome, InquireError> {
let chosen = inquire::Select::new("Language?", vec!["Rust", "Go", "PHP"]).prompt()?;
state.language = Some(chosen.to_owned());
Ok(StepOutcome::Next)
}
}
struct AskFormat;
#[async_trait]
impl WizardStep<Profile> for AskFormat {
fn name(&self) -> &'static str {
"output-format"
}
async fn prompt(&self, state: &mut Profile) -> Result<StepOutcome, InquireError> {
state.as_json = inquire::Confirm::new("Print the summary as JSON?")
.with_default(state.as_json)
.prompt()?;
Ok(StepOutcome::Next)
}
}
Both assign to a field rather than appending to anything. That's the
one rule steps have to follow: a step can run more than once, so it
must be safe to run more than once. A push here would add a second
entry every time the user came back.
Run the wizard and handle the three ways it can end¶
use std::process::ExitCode;
use rtb_tui::{Wizard, WizardError};
#[tokio::main]
async fn main() -> ExitCode {
let wizard = Wizard::<Profile>::builder()
.initial(Profile::default())
.step(AskName)
.step(AskLanguage)
.step(AskFormat)
.build();
let profile = match wizard.run().await {
Ok(profile) => profile,
Err(WizardError::Cancelled) => {
eprintln!("cancelled — nothing was written");
return ExitCode::from(1);
}
Err(WizardError::Interrupted) => return ExitCode::from(130),
Err(err) => {
eprintln!("{err}");
return ExitCode::from(2);
}
};
println!("{:?} in {:?}", profile.name, profile.language);
ExitCode::SUCCESS
}
.initial(...) is not optional. Leave it out and build() panics at
runtime with Wizard::builder requires .initial(...) — the builder
doesn't catch that at compile time.
The last arm is required by the compiler, not by politeness:
WizardError is #[non_exhaustive], so a future version can add a
variant and your match still has to compile.
Exit code 130 for the interrupt is the shell convention for
Ctrl+C, and there's no message with it because
the terminal has already echoed ^C.
Try it, and go back a step¶
Answer the first two questions. When the third one appears, press Esc:
You'll land back on the language question, with the wizard waiting for a new answer. Escape means back, not quit — that's the whole reason the wizard exists rather than three prompts in a row.
Now press Esc on the first question. That one does quit, because there's nowhere further back to go:
Render the answers as a table or JSON¶
Replace the println! at the end of main with something that renders
properly. First, a row type — one struct carrying both derives, because
render_table needs Tabled and render_json needs Serialize:
use rtb_tui::{render_json, render_table};
use serde::Serialize;
use tabled::Tabled;
#[derive(Tabled, Serialize)]
struct Row {
field: &'static str,
value: String,
}
fn rows(profile: &Profile) -> Vec<Row> {
vec![
Row { field: "name", value: profile.name.clone().unwrap_or_default() },
Row { field: "language", value: profile.language.clone().unwrap_or_default() },
]
}
Then pick the renderer from the answer to the third question:
let rows = rows(&profile);
let rendered = if profile.as_json {
match render_json(&rows) {
Ok(json) => json,
Err(err) => {
eprintln!("{err}");
return ExitCode::from(2);
}
}
} else {
render_table(&rows)
};
print!("{rendered}");
ExitCode::SUCCESS
print!, not println!. Both helpers already end their output with a
newline, so println! would add a blank line.
Run it again and answer n to the last question:
And y:
Add a progress line¶
The rendering is instant, so there's nothing to wait for — but the shape is worth seeing. Put a spinner around the work, with a sleep standing in for something slow:
use rtb_tui::Spinner;
let mut spinner = Spinner::new("building the summary…");
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
spinner.set_message("rendering…");
// … the render block from the previous step …
spinner.finish();
print!("{rendered}");
Run it and you'll see ⠋ building the summary… for three quarters of a
second, then ⠋ rendering…, then the line vanishing and the table
appearing in its place.
Two things you'll notice, and both are deliberate. The glyph doesn't
move — it's redrawn only when you call set_message, and there's no
background task animating it. And the spinner writes to stderr, not
stdout, which is why cargo run > out.txt captures the table on its
own with no spinner text mixed in.
If you take that last command further and run the whole thing with stdout and stderr redirected, the spinner disappears entirely rather than filling the file with escape sequences. That check happens once, when the spinner is constructed, and it's the reason these tools are readable in CI logs.
What to read next¶
- Map wizard errors to exit codes — the error handling above, taken seriously.
- Test a wizard without a terminal — how to get this under test, given that it needs a TTY to run.
- Why escape means back — and what the design costs you.
- What rtb-tui does not do — worth reading before you plan anything larger on it.