We open a new series. The previous one walked through building a CLI task manager in Rust with hexagonal architecture, from domain modeling to JSON persistence and clap-based command parsing. The last chapter closed with a question: if the CLI model starts to cause friction, what comes next?
This series is the answer. The rat takes control of the kitchen. We are replacing the CLI adapter with an interactive TUI built on ratatui, and the journey starts here: scaffolding, design decisions, and the first encounters with Rust’s ownership model in a persistent event loop.
The chef swap: from crab to rat #
The previous series ended with a metaphor: the crab (the CLI, powered by clap) hands the pass to the rat (the TUI, powered by ratatui). Same kitchen, same ingredients, different plating.
That metaphor was not just narrative flair. It describes a real architectural operation: swapping one adapter without touching the layers underneath. If hexagonal architecture has been doing its job since chapter 1, this swap should be surgical. No domain changes. No use case changes. No port changes. Just a new directory under adapters/.
The rat sniffs the wiring, finds the right cable, and chews through exactly one connection. Let us see if the rest of the circuit holds.
What changes in the Cargo.toml #
The first commit is boring and that is the point. We remove clap and add two new dependencies:
[dependencies]
ratatui = "0.29"
crossterm = "0.28"
# clap removedRatatui is the TUI framework: widgets, layouts, rendering. Crossterm is the terminal backend: raw mode, event polling, alternate screen. They work together but have distinct responsibilities. Ratatui draws; crossterm talks to the terminal.
Why crossterm and not termion? Crossterm is cross-platform (Windows, macOS, Linux) and is the default backend for ratatui. There is no compelling reason to choose otherwise for a project like this.
The clap removal is significant. The previous CLI was a pub mod cli inside adapters/. By removing it, we are not adding a second interface alongside the CLI. We are replacing it entirely. The CLI approach of todo-cli add "Buy milk" is gone; the TUI takes over as the sole primary adapter.
Note: The previous post discussed keeping both CLI and TUI in the same binary via a
Tuisubcommand in clap. That is a valid design. For this implementation, I chose the clean swap: one adapter at a time, validating that the hexagonal boundaries hold under a full replacement, not a coexistence. If you want coexistence, the technique described in the previous post (adding aTodoCommand::Tuivariant) still applies.
Module structure: following the convention #
The CLI adapter lived under src/tasks/adapters/cli/ with three files: cli_command.rs (parsing), printer.rs (output), errors.rs (error types). The TUI follows the same organizational logic:
src/tasks/adapters/
tui/
mod.rs // Module root, public exports
app.rs // Model: state + update logic (TEA)
ui.rs // View: rendering function
event.rs // Key handling and input mapping
errors.rs // TUI-specific error typesThe mapping to the TEA pattern (The Elm Architecture) is deliberate:
| TEA concept | File | Responsibility |
|---|---|---|
| Model | app.rs |
App struct with all UI state, methods to mutate it |
| View | ui.rs |
fn draw(app: &App, frame: &mut Frame) — pure rendering |
| Update | event.rs |
Key-to-action mapping, delegates to App methods |
And errors.rs wraps both ApplicationError and io::Error into a TuiError type, because the TUI has to handle I/O failures (terminal crashes) that the CLI never faced.
In adapters/mod.rs, the swap is one line:
// pub mod cli; // removed
pub mod tui; // added
The rest of the codebase does not notice. This is hexagonal architecture doing its job in the most boring way possible.
Making invalid states unrepresentable: the InputMode enum #
Here is where design starts. A TUI app needs to behave differently depending on what the user is doing:
- Normal mode: navigating the task list. Keys are shortcuts:
ato add,dto delete,xto toggle status,qto quit. - Adding mode: typing a new task title. Keys are text input:
qtypes the letter “q”, it does not quit.Enterconfirms,Esccancels. - ConfirmDelete mode: asking “Delete this task? (y/n)”. Only
y,n, andEscare accepted. Everything else is ignored.
These modes are mutually exclusive. The app is in exactly one mode at any time. You are either navigating, or typing, or confirming. Never two at once.
The tempting approach is boolean flags (is_adding: bool, is_confirming_delete: bool). Two booleans create four combinations, but only three are valid. The fourth, adding and confirming at the same time, is nonsense the type system happily accepts. With n flags, you get 2^n representable states but only n + 1 valid ones. The ratio degrades exponentially.
The fix is an enum:
enum InputMode {
Normal,
Adding,
ConfirmDelete,
}Three possible values. One per valid state. The invalid combination cannot exist in memory. The compiler enforces it. When I later added Editing as a fourth variant, every match in the codebase flagged a missing arm. No grep, no hoping: the compiler was the state machine verifier.
This is the Rust principle of making invalid states unrepresentable. I wrote a dedicated series (Making Invalid States Unrepresentable 1. Why boolean flags are bugs in disguise) tracing this idea from Yaron Minsky’s original 2010 talk through algebraic data type theory, the Curry-Howard correspondence, and practical examples in Rust, Haskell, OCaml, TypeScript, and Java. If you want the deep dive into why this works mathematically and how it connects to formal methods, that post covers it. Here, we focus on how it applies to our TUI.
What each mode actually does #
Normal
#
The default resting state. The user sees the task list and can:
- Navigate with
j/kor arrow keys - Press shortcut keys:
a(add),d(delete),x(toggle done/todo),e(edit),f(cycle filter),q(quit) - All key events are interpreted as commands, not text
Think of it like Vim’s normal mode: every key is a shortcut.
Adding
#
The user has pressed a and is typing the title of a new task:
- Keys are text input: letters, numbers, spaces are appended to an input buffer
Backspacedeletes the last characterEnterconfirms: creates the task and returns to NormalEsccancels: discards the input and returns to Normal- Shortcut keys are disabled: pressing
qtypes “q”, it does not quit
This mode exists because the same physical keys have completely different meanings. Without it, you could not type a task title containing the letter “d” without triggering a delete confirmation.
ConfirmDelete
#
The user has pressed d on a selected task, and the app asks “Are you sure? (y/n)”:
- Only
y,n, andEscare accepted yconfirms the deletion and returns to NormalnorEsccancels and returns to Normal- All other keys are ignored
- Navigation is disabled: you cannot change the selection during confirmation
This mode exists as a safety net. Deletion removes the task from the JSON file permanently. A confirmation step prevents accidental data loss from a stray keypress.
The state machine #
┌──────────┐
┌───▶│ Normal │◀───┐
│ └────┬─┬───┘ │
│ [a] │ │ [d] │
│ ▼ ▼ │
│ ┌───────┐ ┌──────────────┐
└──│Adding │ │ConfirmDelete │──┘
└───────┘ └──────────────┘
Esc/Enter Esc/y/nThree states, four transitions. Every transition is a single keypress. Every state has a clear “escape hatch” back to Normal. This is the minimum viable interaction pattern for a TUI that supports creation and destructive actions.
The ownership puzzle: what does clone() actually do here? #
With the App struct holding a TaskRepository, we hit a Rust-specific question that would not exist in languages with garbage collection.
The use case services take ownership of the repository:
impl<R: TaskRepository> AddTaskService<R> {
pub fn new(repo: R) -> Self { Self { repo } }
}In the CLI, each service was created once, executed once, and the process exited. In the TUI, the App lives for the entire session. The user might add a task, then list, then delete, then add another. If we move the repo into a service, the App loses it after the first operation.
The solution: self.repo.clone(). But our JsonFileTaskRepository is just a wrapper around a PathBuf, about 50 bytes. Cloning it is noise compared to the I/O the service does next (read file, parse JSON, serialize, write). The alternatives (&mut borrows, Rc<RefCell<>>, changing port signatures) all add complexity for zero benefit on a struct this lightweight.
I wrote a dedicated series on ownership strategies in Rust that covers this topic in depth: what clone does for different types (from trivial PathBuf copies to catastrophic multi-megabyte buffer duplications), the six strategies Rust offers for sharing state (move, borrow, clone, Rc/Arc, interior mutability, Cow), and why the Rust community’s instinctive “clone guilt” is often misplaced. If you want the full picture of when to clone and when not to, that series covers it.
Where things stand #
We have the scaffolding in place:
- Dependencies: ratatui + crossterm, clap removed
- Module structure:
adapters/tui/withapp.rs,ui.rs,event.rs,errors.rs - State machine:
InputModeenum with three variants and clear transition rules - Ownership strategy: clone the repository for each use case invocation, justified by the lightweight nature of the struct
No pixels on screen yet. No event loop running. That is intentional. This post was about decisions, not rendering. The rat has studied the blueprint, mapped every tunnel, and planned the route. The design work that happens before the first frame.render_widget() determines whether the implementation will be clean or chaotic.
In the next post, the rat starts building: the rendering layer that projects state onto the terminal.
Reference code: