We close the series.
The previous post gave the rat plating skills: colors, feedback, empty states, and a modal popup. The TUI looks polished. But there is a subtle performance issue in the event loop, and a missing CRUD operation that will test the architecture one final time.
This post covers two things: fixing input lag with non-blocking polling, and adding the edit feature as a vertical slice that touches three layers without touching the domain. We close with the key takeaways from the entire CLI-to-TUI migration.
Non-blocking event polling: the performance fix #
This change had no visual impact but an immediately noticeable tactile one.
The problem: blocking reads #
The original event loop used event::read(), which blocks indefinitely until a keyboard event arrives:
draw → read (BLOCK until key pressed) → process → draw → read (BLOCK) → ...Every keystroke had to wait for a full draw cycle before the next key could be read. With fast typing, this created perceptible lag: characters appeared in bursts rather than smoothly. The loop was spending most of its time blocked in read(), only drawing when an event forced it to.
The fix: poll with a timeout #
if event::poll(Duration::from_millis(16))? {
if let Event::Key(key) = event::read()? {
// process key
}
}event::poll(16ms) returns immediately if no event is ready. The loop becomes:
draw → poll (return in ≤16ms) → process if event → draw → poll → ...The loop now runs at approximately 60fps (1000ms / 16ms), redrawing the screen even when no keys are pressed. This means the screen is always fresh, and keys are processed the instant they arrive. The 16ms timeout is the standard for TUI applications: responsive enough for typing, light enough on CPU (the terminal diff layer ensures only changed characters are sent).
The difference is immediately noticeable when typing task titles. Characters appear one by one, smoothly, instead of in bursts.
Adding Edit: the vertical slice test #
The real test of any architecture is not the initial build. It is how easy it is to add features later. I had a perfect candidate: the domain already had an edit_title() method that was implemented, validated (rejects empty titles, updates modified_at, preserves created_at), and tested. It was just never wired to any UI.
Adding the edit feature required touching three layers. None of them was the domain.
Layer 1: Application, edit_task.rs
#
A new use case following the exact same pattern as the existing ones:
pub struct EditTaskCommand {
pub task_id: Uuid,
pub new_title: String,
}
pub trait EditTaskUseCase {
fn execute(&mut self, command: EditTaskCommand) -> ApplicationResult<Task>;
}The service orchestration is the standard find-transition-save pattern: find_by_id to get the task, edit_title to apply the domain transition, save to persist. The file was a near-copy of MarkTaskDoneService with different method calls.
Layer 2: TUI adapter #
InputMode::Editing (app.rs): A fourth variant in the enum. The compiler immediately flagged every match on InputMode that needed updating. This is the exhaustive match benefit from Post 1: no grep, no hoping, the compiler tells you exactly where.
start_editing() method: Pre-fills input_buffer with the current task title so the user can modify it rather than retyping from scratch. This is a UX decision that seems obvious but is easy to forget: if the user just wants to fix a typo, forcing them to retype the entire title is hostile.
handle_editing_mode() (event.rs): A new handler function identical to handle_adding_mode in key mapping (Enter, Esc, Backspace, Char) but calling edit_task() on confirmation instead of add_task(). The Adding and Editing modes share behavior but are separate modes because they trigger different actions on Enter. Shared behavior without shared identity.
UI rendering (ui.rs): The popup reuses the same render_input_popup function, adapting title and color based on the mode. Normal mode command bar now shows [e]dit as a new shortcut.
Layer 0: Domain, zero changes #
edit_title() was already implemented. Already validated. Already handling edge cases. The domain was ready before the feature was planned. This is the hexagonal payoff at its most tangible: the domain anticipates features because it models the business, not the UI.
The state machine evolution #
┌──────────┐
┌────────▶│ Normal │◀────────┐
│ └─┬──┬──┬──┘ │
│ [a] │ │ │ [d] │
│ [e] │ │ │ │
│ ▼ │ ▼ │
│ ┌───────┐ │ ┌──────────────┐
├────│Adding │ │ │ConfirmDelete │──┘
│ └───────┘ │ └──────────────┘
│ Esc/Enter │ Esc/y/n
│ ▼
│ ┌─────────┐
└───────│ Editing │
└─────────┘
Esc/EnterFrom 3 states to 4. One enum variant, a few match arms, and the entire feature works. The whole implementation took about 15 minutes. Most of that was the application layer boilerplate.
Verification #
cargo test: 12 passed, 0 failed
cargo clippy: 0 warnings
cargo fmt: cleanThe existing tests still pass. The edit feature did not break anything. Adding a feature validated the architecture the same way the migration did: no unexpected ripples, no hidden coupling, no “oh wait, I need to change the domain too.”
Key takeaways #
Hexagonal architecture earns its keep at swap time #
The overhead of defining ports, keeping layers separate, and resisting the urge to put domain logic in adapters: it all pays off the moment you need to replace a layer. The CLI-to-TUI migration was the proof. Zero domain changes, zero application changes, zero port changes, zero persistence changes. Twelve tests passing unchanged. The architecture did exactly what it promised.
Rust’s enums are a state machine DSL #
InputMode started with 3 variants and grew to 4. Each addition was safe because match is exhaustive. The compiler is your state machine verifier: it will not let you forget a case. If you are modeling mutually exclusive modes and using boolean flags instead of an enum, you are writing bugs the compiler could have caught.
“What does this clone actually do?” is always worth asking #
The answer ranges from “copies 50 bytes of PathBuf” to “duplicates a database connection pool.” Same syntax, wildly different implications. Every time you write .clone() in Rust, know what is inside the struct. For our JsonFileTaskRepository, it was a trivial copy. For a different repository implementation, it might be a different answer entirely.
Non-blocking event loops matter for TUIs #
event::read() vs event::poll() is the difference between typing that feels laggy and typing that feels instant. Always poll with a timeout. 16ms is the standard: 60fps rendering, immediate key processing, minimal CPU overhead.
The last 20% of UX is 80% of the experience #
Color coding, empty states, transient feedback, modal popups: none of these change functionality. But they transform a tool you tolerate into one you enjoy using. The five UX improvements in this post were all technically simple. Their impact on the experience was disproportionate. Never ship a TUI without doing this pass.
Where things stand #
The TUI is complete: add, edit, delete (with confirmation), toggle status, filter by status, keyboard navigation. Color-coded, with feedback messages, modal popups, and responsive event handling. The architecture is clean: domain knows nothing about terminals, the TUI adapter is self-contained, persistence is swappable.
There is a backlog of future modes worth exploring: a help overlay triggered by ?, inline search and filtering, a detail view for full task information, sort options by date or status. Each would be a new InputMode variant and some rendering code. The foundation is ready. Thanks to exhaustive match, the compiler would guide every addition.
The most satisfying moment of the entire project? Running cargo test after the migration and seeing all 12 tests pass unchanged. That is the sound of architectural boundaries holding up under pressure. The crab built the kitchen; the rat took over the plating. Same ingredients, same recipes, same domain, but the rat serves them with color, with feedback, and with a popup that appears exactly where your eyes are looking. The kitchen is open. Bon appetit!
Reference code: