We continue with the series.
The previous post left us with a working TUI: events wired, terminal lifecycle handled, all 12 tests passing, zero domain changes. The hexagonal promise held.
But “working” and “good” are different things. The interface was monochrome, UUIDs ate screen width, successful operations gave no feedback, and an empty task list showed a blank table with no guidance. The rat has built the kitchen. Now it learns to plate.
The last 20% that is 80% of the experience #
There is a well-known rule in product development: the last 20% of polish takes 80% of the effort. The reverse is also true: that 20% represents 80% of the user’s experience. A tool can be architecturally clean and functionally correct, but if the interface is flat, confusing, or silent, nobody will enjoy using it.
The rat tackled five improvements in a focused batch.
1. Color coding: making the interface scannable #
Before: everything was the same color. Status, title, ID, timestamps: all default terminal text. The user had to read each cell to understand the state of a task.
After: color encodes meaning:
- Status:
[ ] TODOin yellow,[x] DONEin green. Instant visual scanning: you can see at a glance how many tasks are pending without reading a single word. - ID and timestamps: dark gray. De-emphasized metadata. It is there if you need it, but it does not compete with the title and status for attention.
- Header: cyan and bold. Clearly distinguishable from data rows.
- Selected row: dark gray background with bold text. Replaces the previous approach where the highlight color could clash with the status color.
- Command bar shortcuts: colored by category. Cyan for creation (
[a]dd,[e]dit), red for destructive ([d]el), yellow for status toggle ([x]done/todo), magenta for filter ([f]ilter). Makes shortcuts scannable at a glance.
This is not decoration. Color is information. When the user looks at the screen, they should be able to answer “how many tasks are done?” in under a second, without reading.
2. Positive feedback messages #
Before: status_message was only set on errors. Successful operations gave zero feedback. The user pressed a key and had to visually scan the table to confirm something happened.
After: every action tells the user what it did:
| Action | Feedback message |
|---|---|
| Add task | "Task added: Buy groceries" |
| Delete task | "Deleted: Buy groceries" |
| Mark done | "Done: Buy groceries" |
| Mark todo | "Todo: Buy groceries" |
| Task not found | "Task not found" |
| Any error | "Error: ..." |
A small implementation detail: the delete feedback captures the task title before the deletion. If you delete first and then try to read the title, the task is gone. The order matters.
Status messages appear in the status bar and are cleared when the user navigates (presses j/k or arrow keys). This makes them transient: visible long enough to confirm the action, gone as soon as you move on. Not persistent clutter.
3. Empty state handling #
Before: when the task list was empty, the table rendered with just a header and blank space. No indication of what to do.
After: when app.tasks.is_empty(), the table is replaced with a centered Paragraph:
No tasks yet. Press [a] to add one.Dark gray text, centered in the bordered area. The user immediately knows what to do. This is a small change with disproportionate impact on first-time users. An empty screen is confusing; a guiding message is welcoming.
4. Status message auto-clear #
Before: status messages persisted indefinitely until another action replaced them. Navigate around for five minutes and still see “Task added: Buy groceries” from earlier.
After: a clear_status() method is called from event.rs whenever the user navigates or enters a new mode. Action methods (add_task, delete_task, cycle_todo_done) manage their own status messages. The result: messages appear briefly after an action and disappear on the next navigation. Transient feedback, not persistent noise.
5. Visual cursor in Adding mode #
Before: in Adding mode, the user typed text but there was no cursor indicator. Just text appearing character by character.
After: a cyan █ (full block) character appears after the input buffer text. It simulates a blinking cursor. The full block is the universally recognized terminal cursor shape, unlike _ (underscore) which could be confused with an actual underscore in the text.
New: Buy groc█ [Enter] confirm [Esc] cancelThe user can see exactly where they are typing. A small detail, but input without a cursor feels broken.
The input popup: from command bar to modal #
The five improvements above were quick wins. The popup was a bigger architectural change in the View layer.
The problem with inline input #
Originally, text input happened in the command bar: a single row at the bottom of the screen. It worked, but it was cramped, easy to miss, and mixed input text with help text. For a task title like “Write the quarterly report for the marketing department”, the single-row command bar would truncate or overlap the help text.
The solution: a centered modal popup #
A modal popup is the standard UX pattern for text input in TUI apps. It draws the user’s attention, provides clear visual boundaries, and has room for both the input field and help text:
│ ┌──────────────────────┐ │
│ │ New Task │ │
│ │ │ │
│ │ Buy groceries█ │ │
│ │ │ │
│ │ [Enter] ok [Esc] no │ │
│ └──────────────────────┘ │Implementation: centered_rect and render order #
The popup is built in two steps:
Step 1: calculate a centered rectangle.
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
]).split(area);
Layout::horizontal([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
]).split(vertical[1])[1]
}Nested vertical + horizontal splits. The outer margins are calculated as (100 - target_percent) / 2. The result is the inner Rect at the center. Clean geometry.
Step 2: render the popup on top of the normal UI.
pub fn draw(app: &App, frame: &mut Frame, table_state: &mut TableState) {
// Normal rendering: table, command bar, status bar
render_table(app, frame, table_state);
render_command_bar(app, frame);
render_status_bar(app, frame);
// Popup overlay (only in Adding or Editing mode)
if matches!(app.input_mode, InputMode::Adding | InputMode::Editing) {
render_input_popup(frame, app);
}
}The popup renders after the normal UI. Ratatui’s immediate-mode rendering makes this trivial: whatever you draw last overwrites whatever is underneath. To ensure a clean background, a Clear widget is rendered first in the popup area, wiping any table content that was painted in the first pass.
The popup title and color adapt to the mode: cyan for “New Task” (Adding), yellow for “Edit Task” (Editing). The user sees at a glance whether they are creating or modifying.
Where things stand #
The rat can plate. Colors encode meaning, feedback confirms actions, empty states guide new users, and text input happens in a focused popup instead of a cramped bottom bar.
But there is a subtle performance issue hiding in the event loop, and a missing CRUD operation (edit) that will test the architecture one more time. The next post closes the series with non-blocking event polling and the edit feature as a full vertical slice.
Reference code: