rtb-tui 0.6.1

Reusable TUI widgets: forms, wizards, prompts, tables. Part of the phpboyscout Rust toolkit.
Documentation
---
title: rtb-tui
description: Reusable TUI building blocks — Wizard, render helpers, TTY-aware Spinner.
---

# `rtb-tui`

Three small building blocks every CLI tool needs and would otherwise
have to roll itself: a multi-step interactive `Wizard`, uniform
structured-output render helpers, and a TTY-aware `Spinner`.

Part of the [phpboyscout Rust toolkit](https://rust.phpboyscout.uk);
extracted from — and battle-tested by —
[rust-tool-base](https://gitlab.com/phpboyscout/rust-tool-base).

## Public API

```rust
use rtb_tui::{Wizard, WizardStep, StepOutcome, render_table, render_json, Spinner};
```

| Item | Purpose |
|---|---|
| `Wizard<S>` / `WizardBuilder<S>` | Multi-step interactive form with escape-to-back navigation, backed by `inquire`. |
| `WizardStep<S>` | Async trait a step implements; receives `&mut S`. |
| `StepOutcome` | `Next` advances, `Back` re-runs the previous step. |
| `WizardError` | `Cancelled`, `Interrupted`, or `Step { step, message }`. |
| `render_table<R: Tabled>(rows)` | Infallible psql-style text table. |
| `render_json<R: Serialize>(rows)` | Pretty-printed JSON array; `RenderError::Json` on a failing `Serialize` impl. |
| `Spinner` | TTY-aware progress indicator; every method no-ops when stderr isn't a terminal. |
| `InquireError` | Re-export so `WizardStep` impls can `?`-propagate without a direct `inquire` dependency. |

Full API reference: [docs.rs/rtb-tui](https://docs.rs/rtb-tui).

## `Wizard`

Multi-step interactive form backed by
[`inquire`](https://crates.io/crates/inquire).

```rust
use rtb_tui::{Wizard, WizardStep, StepOutcome, InquireError};
use async_trait::async_trait;

struct Greet;

#[async_trait]
impl WizardStep<Profile> for Greet {
    fn name(&self) -> &'static str { "greet" }
    async fn prompt(&self, state: &mut Profile) -> Result<StepOutcome, InquireError> {
        state.greeting = Some(inquire::Text::new("Hello, what should I call you?").prompt()?);
        Ok(StepOutcome::Next)
    }
}

let profile = Wizard::<Profile>::builder()
    .initial(Profile::default())
    .step(Greet)
    .build()
    .run()
    .await?;
```

### Navigation rules

- A step that returns `StepOutcome::Next` advances; if it was the last
  step, `run` finishes.
- A step that returns `StepOutcome::Back` re-runs the previous step. If
  the wizard is on step 0, `run` returns `WizardError::Cancelled`.
- A step that returns `Err(InquireError::OperationCanceled)` (Esc) is
  treated identically to `StepOutcome::Back` — the driver maps it for
  you, so steps just `?`-propagate.
- `Err(InquireError::OperationInterrupted)` (Ctrl+C) short-circuits to
  `WizardError::Interrupted` regardless of position.
- Any other `InquireError` is wrapped in
  `WizardError::Step { step, message }` with the step's name attached
  for diagnosis.

### State threading

`Wizard<S>` owns its state. Each step receives `&mut S`, so step *N+1*
sees mutations made by step *N*. When the user backs into a previous
step, the step re-runs against the **current** state — implementations
should be idempotent (using current state to default-fill `inquire`
prompts is the canonical pattern).

## Render helpers

```rust
use rtb_tui::{render_table, render_json};
use serde::Serialize;
use tabled::Tabled;

#[derive(Tabled, Serialize)]
struct Row { name: &'static str, count: u32 }

let rows = vec![Row { name: "alpha", count: 1 }];

print!("{}", render_table(&rows));               // psql-style text table
print!("{}", render_json(&rows).unwrap());       // pretty-printed JSON array
```

Both helpers add a trailing newline so callers can `print!` directly
without their own `println!`.

`render_table` is infallible (`tabled` cannot fail over a
`Tabled`-deriving type). `render_json` returns `RenderError::Json(_)`
when a row's `Serialize` impl fails — always programmer mistake
(non-`Serialize`-clean shape), never user input.

## `Spinner`

```rust
use rtb_tui::Spinner;

let mut s = Spinner::new("downloading…");
// … work …
s.set_message("verifying signature…");
// … work …
s.finish();   // explicit; the Drop impl also calls finish()
```

When stderr isn't a TTY (CI logs, MCP-stdio transports), every method
on `Spinner` is a no-op — no escape sequences leak into captured
output. The spinner is single-threaded by design: there is no internal
`tokio::task::spawn` that animates frames. Tick the spinner manually
via `set_message` between awaits.

## Design record

The authoritative contract is the crate's v0.1 spec, retained in the
rust-tool-base
[spec series](https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/development/specs/2026-05-06-rtb-tui-v0.1.md).