Skip to main content
  1. Posts/

Todo TUI in Rust 3. Events, terminal safety, and the migration moment of truth

·7 mins
Rafael Fernandez
Author
Rafael Fernandez
Mathematics, programming, and life stuff
Todo TUI in Rust - This article is part of a series.
Part 3: This Article

We continue with the series.

In the previous post the rat learned to paint: layout, table, command bar, status bar. The screen looks right, but nobody can interact with it yet.

Now the rat wires up the ears. This post covers the Event layer (mapping keys to actions), the terminal lifecycle (the thing nobody thinks about until their terminal is stuck in raw mode), and the migration moment of truth: running cargo test and discovering whether hexagonal architecture kept its promise.

The Event layer: event.rs
#

The Event layer bridges crossterm keyboard input to App state mutations. Each InputMode has its own handler function, because the same key means completely different things depending on the mode.

Mode-specific handlers
#

pub fn handle_events(app: &mut App) -> Result<(), TuiError> {
    if event::poll(Duration::from_millis(16))? {
        if let Event::Key(key) = event::read()? {
            if key.kind != KeyEventKind::Press { return Ok(()); }
            match app.input_mode {
                InputMode::Normal => handle_normal_mode(app, key),
                InputMode::Adding => handle_adding_mode(app, key),
                InputMode::ConfirmDelete => handle_confirm_delete_mode(app, key),
            }
        }
    }
    Ok(())
}

Three separate functions. Each one matches on KeyCode and calls the appropriate App method. This separation is not just organizational: it makes each handler shorter and ensures that keys are only interpreted in their correct context. Pressing q in Normal mode quits the app; pressing q in Adding mode types the letter “q”. The dispatch happens before the key is interpreted, not after.

Guards against empty lists
#

In Normal mode, destructive and selection-dependent actions are guarded:

fn handle_normal_mode(app: &mut App, key: KeyEvent) {
    match key.code {
        KeyCode::Char('d') if !app.tasks.is_empty() => {
            app.enter_confirm_delete();
        }
        KeyCode::Char('x') if !app.tasks.is_empty() => {
            app.cycle_todo_done();
        }
        // ...
    }
}

The if !app.tasks.is_empty() guard prevents index-out-of-bounds panics when the task list is empty. Without it, pressing d on an empty list would try to access self.tasks[self.selected], which panics. The guard is simple, but it is the kind of thing that bites you during testing when you forget to populate the list first.

The key redesign: toggle instead of separate keys
#

The original plan had d for “mark done” and t for “mark todo”. During implementation, this felt unergonomic: the user has to know the current status of the task to pick the right key. Instead, I introduced x as a toggle (cycle_todo_done) that automatically switches between Done and Todo based on the current status:

pub fn cycle_todo_done(&mut self) {
    let status = self.tasks[self.selected].status();
    match status {
        TaskStatus::Done => self.mark_todo(),
        TaskStatus::Todo => self.mark_done(),
    }
}

One key instead of two. The user does not need to remember the task’s state: just press x and it flips. This freed up d for delete (entering ConfirmDelete mode), which is more conventional: d for delete, x for toggle.

The mark_done() and mark_todo() methods became private. They are implementation details behind the public toggle. The external API of App is cleaner.

Backspace: the one-liner
#

KeyCode::Backspace => { app.input_buffer.pop(); }

If the buffer is empty, pop() returns None and nothing happens. No bounds checking, no special case. Rust’s String::pop() handles it cleanly.

The terminal restore problem
#

Here is a gotcha that is specific to TUI applications and easy to overlook until it happens.

When ratatui::init() runs, it does three things to the terminal:

  1. Enables raw mode: keystrokes are sent one-at-a-time instead of line-buffered. Without restoring, the terminal will not process Enter, arrow keys, or line editing properly after the app exits.
  2. Enters alternate screen: the TUI draws on a separate buffer so it does not overwrite the user’s scrollback history. Without restoring, the user is stuck on the alternate screen with no way to see their previous commands.
  3. Hides the cursor: cleaner TUI appearance. Without restoring, the cursor stays invisible after exit.

If the main loop exits via ? (an error in handle_events, draw, or a repository operation), execution jumps directly out of the function and ratatui::restore() never runs. The user’s terminal is left in raw mode with no cursor. They would need to type reset blindly or close the terminal window.

This is not a hypothetical scenario. It happens every time there is an unexpected I/O error, a malformed JSON file, or a domain error that bubbles up uncaught. During development, it happens frequently.

The fix: capture result, cleanup, return result
#

Rust does not have finally. But there is a clean pattern:

fn run<R: TaskRepository + Clone>(repo: R) -> Result<(), Box<dyn std::error::Error>> {
    let mut app = App::new(repo)?;
    let mut terminal = ratatui::init();
    let mut table_state = TableState::default();

    let result = run_loop(&mut terminal, &mut app, &mut table_state);

    ratatui::restore();  // ALWAYS executes, error or not
    result               // THEN propagate the error if any
}

The key insight: ratatui::restore() is called between capturing the error and returning it:

  1. run_loop executes and returns Ok(()) or Err(...)
  2. ratatui::restore() runs unconditionally
  3. The original result is returned, propagating the error if any

This is the Rust equivalent of Go’s defer or Java’s finally. An alternative would be implementing Drop on a wrapper struct, but that is overkill for a one-shot cleanup call. The “capture, clean, return” pattern is simpler and is the same approach used in ratatui’s official examples.

Rewiring main.rs
#

The final step is connecting the TUI to the application entry point:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let repo = JsonFileTaskRepository::new()?;
    tui::run(repo)?;
    Ok(())
}

Compare this to the previous CLI’s main.rs, which parsed clap arguments, matched on subcommands, constructed services, formatted output, and handled errors with a printer module. The TUI’s main.rs is two lines of meaningful code. All the complexity has moved into the TUI adapter itself, where it belongs.

The migration moment of truth
#

Seven steps done. Dependencies updated, modules scaffolded, state designed, view implemented, events wired, main rewired, terminal lifecycle handled. Time to see if anything broke.

$ cargo build --verbose
   Compiling todo-cli v0.1.0
    Finished `dev` profile

$ cargo test --verbose
running 12 tests
test ... ok (x12)
test result: ok. 12 passed; 0 failed

$ cargo clippy --all-targets --all-features -- -D warnings
    Checking todo-cli v0.1.0
    Finished

All green. And here is the part that validates the architecture:

Files changed in the migration
#

File Change
Cargo.toml Added ratatui + crossterm, removed clap
src/main.rs Rewired from clap dispatch to TUI loop
src/tasks/adapters/mod.rs pub mod cli replaced with pub mod tui
src/tasks/adapters/tui/mod.rs New module root
src/tasks/adapters/tui/errors.rs New: TuiError enum
src/tasks/adapters/tui/app.rs New: App state, InputMode, use case orchestration
src/tasks/adapters/tui/ui.rs New: rendering
src/tasks/adapters/tui/event.rs New: event handling

Files NOT changed
#

Layer Change count
src/tasks/domain/ 0
src/tasks/application/ 0
src/tasks/ports/ 0
src/tasks/adapters/persistence/ 0
All 12 existing tests 0 modifications, all passing

Zero changes to domain, application, ports, or persistence. All 12 existing tests passed without modification. The hexagonal architecture did exactly what it promised: the entire presentation layer was a replaceable adapter, and replacing it touched nothing else.

The domain did not know or care whether it was being driven by clap arguments or ratatui key events. The use cases did not know or care whether their results were printed to stdout or rendered as table rows. The repository did not know or care whether it was called once per process or fifty times in a session.

That is the hexagonal payoff. The rat rewired the entire front of the restaurant without the kitchen noticing. And it was the most satisfying cargo test I have run in this project.

Clippy fixes along the way
#

Four clippy lints were caught and fixed during the verification pass:

  1. unit_arg in select_next/select_previous: wrapping self.selected += 1 inside Ok() passes () as an argument. Fixed by moving the mutation before the Ok(()) return.
  2. useless_format in ui.rs: format!("{}", task.title()) where task.title().to_string() is clearer and avoids the format macro overhead.
  3. collapsible_if in event.rs: nested if let + if collapsed into a single condition using let-chains (&&), reducing indentation. Uses Rust’s let-chains feature, stable since 1.87.

Clippy caught what even the sharpest rat misses. Running it with -D warnings (deny all warnings) is not optional in this project.

Where things stand
#

The TUI is functional: you can add tasks, list them, delete them (with confirmation), toggle their status, filter by status, and navigate with keyboard. All powered by the same domain, use cases, and repository that the CLI used.

But “functional” and “good” are not the same. The current interface is monochrome, UUIDs are still prominent, there is no feedback when actions succeed, and the empty state is an awkward blank screen. The rat has built the kitchen. Now it needs to learn plating. The next post is about the polish: colors, transient feedback, empty states, and a modal popup for text input.

Reference code:

Todo TUI in Rust - This article is part of a series.
Part 3: This Article