Skip to main content
  1. Posts/

·25 mins
Rafael Fernandez
Author
Rafael Fernandez
Mathematics, programming, and life stuff
Table of Contents

LOGBOOK - todo-cli
#

Development diary for todo-cli, a Rust command-line task manager built with hexagonal (ports & adapters) and screaming architecture.


[2026-03-19] - Logbook initialized
#

What: Logbook mode activated. Ready to record development progress for todo-cli (Rust, hexagonal architecture). Why: Having a running dev diary makes it easy to turn the build journey into a blog post later. It also serves as a breadcrumb trail for future-me (or anyone reading the repo) to understand why things were done, not just what changed. How: Created LOGBOOK.md at the repo root. Each session that makes meaningful progress gets a dated entry covering what, why, and how.


[2026-03-19] - Architecture decision: Migrate CLI to TUI with ratatui
#

What: Decided to replace the clap-based CLI adapter with a ratatui TUI adapter. The migration plan preserves the hexagonal architecture — only the presentation adapter changes. Domain, application, and ports layers remain untouched, validating the architectural boundaries.

Why: The user wants an interactive terminal UI. The hexagonal architecture makes this a clean adapter swap — the entire CLI layer (adapters/cli/) gets replaced by a new adapters/tui/ module while domain logic stays pure. This is exactly the kind of moment hexagonal architecture is designed for: swapping an outer-layer adapter without rippling changes into the core. It’s a satisfying validation that the boundaries we drew early on actually hold up.

How:

  • Pattern: TEA (The Elm Architecture) — Model/View/Update loop. The TUI state lives in an App struct, user input triggers Update messages, and the View function renders the current state. Clean separation of concerns even within the adapter itself.
  • New deps: ratatui + crossterm (replacing clap). Crossterm handles raw terminal mode and event polling; ratatui provides the widget/layout layer on top.
  • Module structure: adapters/tui/ with app.rs (state + update logic), ui.rs (rendering/layout), event.rs (key handling and input mapping), and errors.rs (TUI-specific error types).
  • Implementation approach: Step-by-step mentoring mode — building incrementally, verifying at each checkpoint rather than dropping a wall of code.
  • 7 implementation steps planned: (1) add deps to Cargo.toml, (2) scaffold the adapters/tui/ module skeleton, (3) build the App struct with state and update methods, (4) implement the view/rendering layer, (5) wire up event handling, (6) rewire main.rs to launch the TUI instead of the CLI, (7) full verification pass.

[2026-03-19] - Design decision: InputMode enum — making invalid states unrepresentable
#

What: Decided to model TUI application modes (Normal, Adding, ConfirmDelete) as a Rust enum instead of using boolean flags.

Why: This is a core Rust principle called “make invalid states unrepresentable.” The TUI app needs to behave differently depending on what the user is doing:

  • Normal mode: navigating the task list, pressing shortcut keys (a, d, x, q, etc.)
  • Adding mode: typing a new task title, Enter confirms, Esc cancels
  • ConfirmDelete mode: showing a y/n prompt before deleting a task

These modes are mutually exclusive — the app can only be in ONE mode at a time. You’re either navigating, or typing, or confirming. Never two at once.

The boolean approach (bad):

struct App {
    is_adding: bool,
    is_confirming_delete: bool,
}

This creates 4 possible combinations:

  1. (false, false) — Normal — valid
  2. (true, false) — Adding — valid
  3. (false, true) — ConfirmDelete — valid
  4. (true, true) — Adding AND ConfirmDelete at the same time — INVALID, but the type system allows it

You’d need runtime checks everywhere: if self.is_adding && self.is_confirming_delete { panic!("bug!") }. Every function that reads these flags needs to handle the impossible state. Every function that sets one flag needs to remember to unset the other. It’s a class of bugs waiting to happen — and it only gets worse as you add more modes.

The enum approach (good):

enum InputMode {
    Normal,
    Adding,
    ConfirmDelete,
}

This creates exactly 3 possible values — one per valid state. The invalid combination literally cannot exist in memory. The compiler enforces it. No runtime checks needed. When you match on it, the compiler tells you if you forgot a case. Adding a new mode (e.g., Editing) means adding one variant, and the compiler flags every match that needs updating.

This is what the Rust community calls “making invalid states unrepresentable” — using the type system to eliminate entire categories of bugs at compile time instead of catching them at runtime. It’s the same principle behind Option<T> instead of nullable pointers, and Result<T, E> instead of error codes.

How: Defined InputMode enum in adapters/tui/app.rs with three variants: Normal, Adding, ConfirmDelete. The App struct holds a single input_mode: InputMode field instead of multiple boolean flags.


[2026-03-19] - Design detail: InputMode variants — what they represent and future possibilities
#

What: Documented the three InputMode variants chosen for the TUI and explored potential future variants to enrich the interface.

Why: InputMode is the central state machine of the TUI. Each variant defines a completely different “context” of interaction — which keys are active, what the screen shows, and what the user can do. Understanding what each mode represents and why it exists is essential before implementing app.rs.

How:

The three current variants
#

Normal
#

This is the default resting state of the application. The user sees the task list and can:

  • Navigate up/down with j/k or arrow keys
  • Press shortcut keys to trigger actions (a to add, d to mark done, t to mark todo, x to delete, f to cycle filter, q to quit)
  • All key events are interpreted as commands, not as text input

Think of it like Vim’s normal mode — every key is a shortcut.

Adding
#

The user has pressed a and is now typing the title of a new task. In this mode:

  • Key events are interpreted as text input, not commands
  • Letters, numbers, spaces are appended to an input buffer
  • Backspace deletes the last character
  • Enter confirms — creates the task and returns to Normal
  • Esc cancels — discards the input and returns to Normal
  • The shortcut keys (d, t, x, q) are disabled — pressing q types the letter “q”, it doesn’t quit

This mode exists because the same keys mean completely different things. Without it, you couldn’t type a task title containing the letter “q” without quitting the app.

ConfirmDelete
#

The user has pressed x on a selected task, and the app is asking “Are you sure? (y/n)”. In this mode:

  • Only y and n (and Esc) are accepted
  • y confirms the deletion and returns to Normal
  • n or Esc cancels and returns to Normal
  • All other keys are ignored
  • Navigation is disabled — you can’t change the selection while confirming

This mode exists as a safety net. Deletion is destructive and irreversible (the task is removed from the JSON file). A confirmation step prevents accidental data loss from a stray keypress.

Why these three and not more?
#

These three cover the minimum viable interaction patterns:

  1. Command mode (Normal) — for actions that are a single keypress
  2. Text input mode (Adding) — for actions that require free-text entry
  3. Confirmation mode (ConfirmDelete) — for destructive actions that need a safety check

Every interactive TUI needs at least these three patterns. They form a minimal but complete state machine:

         ┌──────────┐
    ┌───▶│  Normal  │◀───┐
    │    └────┬─┬───┘    │
    │    [a]  │ │  [x]   │
    │         ▼ ▼        │
    │  ┌───────┐ ┌──────────────┐
    └──│Adding │ │ConfirmDelete │──┘
       └───────┘ └──────────────┘
       Esc/Enter    Esc/y/n

Future variants to enrich the TUI
#

Here are modes we could add as the app grows:

Editing
#

For editing an existing task’s title. The domain already has edit_title(self, title) -> DomainResult<Self> implemented but no use case or UI calls it. This mode would:

  • Pre-fill the input buffer with the current title
  • Work like Adding but update instead of create
  • Need a new EditTaskUseCase and EditTaskService in the application layer

Help
#

A modal overlay showing all available keybindings. Common in TUI apps (usually triggered by ? or h). Would:

  • Render a centered popup over the task list
  • Dismiss on any keypress
  • No state mutation, purely informational

Searching / Filtering
#

A text input mode for searching tasks by title substring. Would:

  • Show a search bar at the top or bottom
  • Filter the task list in real time as the user types
  • Enter locks the filter, Esc clears it and returns to Normal

DetailView
#

A mode showing full details of the selected task (all fields, timestamps, full UUID). Would:

  • Replace or overlay the list view with a detail panel
  • Useful when titles are long or the user wants to copy the full UUID

Sorting
#

A mode to choose sort order (by created date, modified date, title, status). Would:

  • Show a small popup or cycle through sort options
  • Persist the choice in the App state

Each of these would be a single new variant in the InputMode enum. Thanks to Rust’s exhaustive match, the compiler would force us to handle the new mode in every place that matches on InputMode — making it impossible to forget.


[2026-03-19] - Deep dive: Why clone() on JsonFileTaskRepository is the right call
#

What: Analyzed what clone() does internally when passing the repository to use case services, and why it’s the correct ownership strategy for this scenario.

Why: In app.rs, every time we call a use case (add task, delete task, mark done, etc.) we need to construct a Service that takes ownership of a TaskRepository. Since App needs to keep its repo alive for future operations, we clone it. Understanding what this clone actually does — and what the alternatives are — is important for making informed decisions about ownership.

How:

What is JsonFileTaskRepository internally?
#

#[derive(Debug, Clone)]
pub struct JsonFileTaskRepository {
    file_path: PathBuf,  // that's it — just a path string
}

It’s a wrapper around a single PathBuf pointing to ~/.config/todo-cli/data/tasks.json. There is no open file handle, no connection pool, no in-memory cache. It’s essentially a glorified string.

What clone() does at runtime
#

When we call self.repo.clone():

  1. A new PathBuf is allocated on the heap
  2. The bytes of the path string (~50 bytes like /home/user/.config/todo-cli/data/tasks.json) are copied
  3. The new JsonFileTaskRepository points to the same file on disk with its own independent copy of the path

What it does NOT do: it does not copy the JSON file, open a file handle, or duplicate any I/O state. Two cloned repos are just two structs that happen to read/write the same file.

Why we need the clone
#

The use case services take ownership of the repository:

pub fn new(repo: R) -> Self { Self { repo } }

If we passed self.repo directly (a move), App would lose its repo field — it would be consumed by the service. After creating one task, we couldn’t list, delete, or do anything else. The service lives briefly (one operation), but the repo needs to live for the entire App lifetime.

Clone gives the service its own copy while App retains the original.

Is it expensive?
#

No. Cloning ~50 bytes of path string is negligible compared to what the service does next: opening a file, reading the entire JSON, deserializing it, modifying the task list, serializing it back, and writing it to disk. The clone cost is noise next to the I/O.

Alternatives considered and their trade-offs
#

Strategy How it works Trade-off
clone() (chosen) Each service gets its own copy of the repo Simple, clear, negligible cost for a PathBuf
&mut self on services Services borrow the repo by mutable reference Complicates lifetimes — the service can’t outlive the borrow, and you can’t hold two services at once
Rc<RefCell<R>> or Arc<Mutex<R>> Shared ownership with interior mutability Massively overengineered for a struct that’s just a PathBuf. Adds runtime borrow checking or locking overhead and complexity for zero benefit
Make traits generic over &R Services work with references to the repo Requires changing the TaskRepository port trait signatures, which breaks the existing contract and affects all layers

When would clone be the WRONG choice?
#

If JsonFileTaskRepository held expensive state — a database connection pool, a large in-memory cache, an open network socket — then cloning would either be impossible (types that don’t implement Clone) or wasteful (duplicating heavy resources). In those cases, you’d use shared references (Rc/Arc) or restructure ownership.

But for a PathBuf wrapper, clone is the textbook correct answer.


[2026-03-21] - Implemented ui.rs — the View layer of the TUI
#

What: Completed the rendering layer (ui.rs) which is the View in the TEA pattern. It draws three zones: the task table with selection highlighting, a context-sensitive command bar, and a status bar showing the active filter and task count.

Why: The View is a pure rendering function — it takes &App and a Frame, draws widgets, and mutates nothing. This separation keeps rendering testable and decoupled from state logic. The command bar changes based on InputMode, giving the user contextual hints about what keys are available in each mode.

How:

  • Layout: Layout::vertical splits the screen into three zones — main table (flexible), command bar (1 row), status bar (1 row). Clean and predictable. The table gets all the space it needs, while the bottom two rows are fixed chrome.
  • Table: Uses render_stateful_widget with TableState for row highlighting. Columns are ID, Task, Status, Created At, Modified At. The highlight symbol ">> " marks the selected row — a small touch that makes keyboard navigation feel immediate.
  • Command bar: A Paragraph whose content changes via match app.input_mode — Normal mode shows the available shortcuts (a: add, d: done, x: delete, etc.), Adding mode shows the input buffer with Enter/Esc hints, and ConfirmDelete mode shows the y/n prompt. Each render_command match arm returns a Paragraph directly, avoiding temporary string lifetime issues that would crop up if we returned &str from a local String.
  • Status bar: Shows "Filter: {filter} | {n} tasks" using Display formatting. Simple, informative, always visible. The filter label comes from TaskStatusFilter’s Display impl, which means adding a new filter variant automatically shows up here.
  • Border and title: The table is wrapped in a Block::bordered() with a centered title "TODO Tasks". The block is passed to the Table via .block() so the table renders inside the border correctly — an important detail because if you render the block separately and the table on top, they fight for the same area.
  • Key design decisions: Keeping ui.rs as a single public fn draw(app: &App, frame: &mut Frame) function reinforces the TEA pattern — the entire rendering pass is one function call with no hidden state. This makes it trivial to reason about what’s on screen: whatever draw paints is the truth, derived entirely from App.

[2026-03-21] - Implemented event.rs and refined app.rs — completing the TUI adapter core
#

What: Completed the event handling layer (event.rs) and added a new cycle_todo_done() method to app.rs. All four TUI adapter files (errors.rs, app.rs, ui.rs, event.rs) now compile cleanly with zero warnings.

Why: The event layer bridges crossterm keyboard input to App state mutations. The key mapping was redesigned from the original plan: instead of separate d (mark done) and t (mark todo) keys, we introduced x as a toggle (cycle_todo_done) that automatically switches between Done/Todo based on current status. This is more intuitive — one key instead of two, and the user doesn’t need to remember which state the task is in. d was reassigned to delete (enter ConfirmDelete mode), which is more conventional (d for delete).

How:

  • event.rs: Three handler functions by mode — handle_normal_mode, handle_adding_mode, handle_confirm_delete_mode. Each receives &mut App and a KeyEvent, matches on KeyCode, and calls the appropriate App method.
  • Guards: !app.tasks.is_empty() protects d (delete) and x (toggle) in normal mode to prevent index-out-of-bounds panic when the task list is empty.
  • app.rs: Added cycle_todo_done() as a public method that reads self.tasks[self.selected].status() and delegates to private mark_done() or mark_todo(). Made mark_done and mark_todo private since they’re now implementation details behind the toggle.
  • ui.rs: Updated command bar to [a]dd [d]el [x]done/todo [f]ilter [q]uit to match the actual key bindings.
  • Backspace simplified: just app.input_buffer.pop() — if buffer is empty, pop returns None and nothing happens.

[2026-03-21] - Design consideration: Guaranteeing terminal restore on error
#

What: Identified a potential issue in main.rs where an error during the TUI loop could leave the terminal in a broken state (raw mode active, alternate screen visible, cursor hidden). Proposed a pattern to guarantee ratatui::restore() always executes.

Why: When a TUI application starts, ratatui::init() 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 won’t 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 doesn’t 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 look. Without restoring, the cursor stays invisible after exit.

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

This is NOT a hypothetical problem. It happens every time there’s an unexpected I/O error, a malformed JSON file, or a domain error that isn’t caught by the status_message pattern. In development, it happens frequently when testing error paths.

How: The proposed fix separates the loop into its own function so the result can be captured before restoring:

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

    // Loop in a separate function so we can capture the Result
    let result = loop_app(&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. The control flow is:

  1. loop_app runs — returns Ok(()) or Err(...)
  2. ratatui::restore() runs — ALWAYS, regardless of step 1
  3. result is returned — propagating the original error if any

This is similar to a finally block in Java/Python, or a defer in Go. Rust doesn’t have finally, but the “capture result, cleanup, return result” pattern achieves the same thing. An alternative would be implementing Drop on a wrapper struct, but that’s overkill for a one-shot cleanup.

This is the same pattern used in ratatui’s official examples and documentation. It’s considered a best practice for any TUI application.

Status: Identified but not yet applied. The current main.rs works correctly for the happy path — this fix would be applied before the final verification step.


[2026-03-21] - TUI migration complete — all 7 steps done, build + tests + clippy clean
#

What: Completed the full migration from clap CLI to ratatui TUI. All 7 planned steps are done. The project builds, all 12 existing tests pass, and clippy reports zero warnings with -D warnings.

Why: This was the primary goal of the session — replace the CLI adapter with an interactive TUI while preserving the hexagonal architecture. The fact that domain, application, ports, and persistence layers required ZERO changes validates the architectural boundaries drawn at the start of the project.

How:

Final verification results
#

  • cargo build --verbose: OK
  • cargo test --verbose: 12 passed, 0 failed
  • cargo clippy --all-targets --all-features -- -D warnings: 0 errors, 0 warnings

Clippy fixes applied during verification
#

Four clippy lints were caught and fixed:

  1. unit_arg in select_next/select_previous — was wrapping self.selected += 1 inside Ok(), which passes () as an argument. Fixed by moving the mutation before the Ok(()) return.
  2. useless_format in ui.rsformat!("{}", task.title()) when 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 if let with let-chains (&&), reducing indentation. Uses Rust’s let-chains feature (stable since 1.87).

Files changed in the migration
#

  • Cargo.toml — added ratatui + crossterm, removed clap
  • src/main.rs — rewired from clap dispatch to TUI loop with terminal restore safety
  • src/tasks/adapters/mod.rspub mod cli replaced with pub mod tui
  • src/tasks/adapters/tui/mod.rs — new module root
  • src/tasks/adapters/tui/errors.rs — TuiError enum (wraps ApplicationError + io::Error)
  • src/tasks/adapters/tui/app.rs — App state, InputMode enum, all use case orchestration
  • src/tasks/adapters/tui/ui.rs — rendering (table + command bar + status bar)
  • src/tasks/adapters/tui/event.rs — crossterm event handling, key-to-action mapping

Files NOT changed (architecture validated)
#

  • src/tasks/domain/ — zero changes
  • src/tasks/application/ — zero changes
  • src/tasks/ports/ — zero changes
  • src/tasks/adapters/persistence/ — zero changes
  • All 12 existing tests — still pass without modification

[2026-03-21] - UX improvements: Visual polish, feedback messages, and empty state handling
#

What: Applied 5 UX improvements to the TUI in a single batch, transforming it from a functional prototype into a polished interface.

Why: The initial TUI implementation was correct but visually flat — no color differentiation, full UUIDs eating screen space, raw ISO timestamps, no user feedback on actions, and no guidance when the task list is empty. These are all “last mile” details that make the difference between a tool you tolerate and one you enjoy using.

How:

Improvement 1: Visual polish (ui.rs)
#

Problem: Everything was the same color, UUIDs were 36 characters wide, timestamps showed full ISO-8601 with timezone, columns had no proportional sizing.

Changes:

  • UUID truncated to 8 charsa1b2c3d4 instead of a1b2c3d4-e5f6-7890-abcd-ef1234567890. First 8 hex chars give enough uniqueness for a personal task list (4 billion combinations). Column width dropped from 36 to 8 characters.
  • Timestamps formatted as “Mar 21 14:30” — using chrono’s format("%b %d %H:%M"). Compact, human-readable, timezone-free (the user’s local context is implied).
  • Column proportions — ID (8), STATUS (10), TITLE (fill remaining), CREATED (12), MODIFIED (12). Title gets all remaining space via Constraint::Fill(1) instead of Min(1) for all columns.
  • Color coding:
    • Status: [ ] TODO in yellow, [x] DONE in green — instant visual scanning
    • ID and timestamps: dark gray — de-emphasized metadata
    • Header: cyan + bold — clearly distinguishable from data rows
    • Border: cyan — consistent with header
    • Selected row: dark gray background + bold — replaces the previous yellow-on-yellow scheme
  • Header with bottom marginRow::bottom_margin(1) adds a visual separator between header and data
  • Styled command bar using Span composition — each shortcut key is colored by category: cyan for creation, red for destructive, yellow for status toggle, magenta for filter. Makes shortcuts scannable at a glance.
  • Status bar — filter label in magenta bold, counts in dark gray, status messages in yellow bold. Both bars have black background to visually separate from the table area.

Improvement 2: Positive feedback messages (app.rs)
#

Problem: 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.

Changes:

  • add_task on success: "Task added: Buy groceries"
  • delete_task on success: "Deleted: Buy groceries" (captures title BEFORE deletion)
  • delete_task when not found: "Task not found"
  • mark_done on success: "Done: Buy groceries"
  • mark_todo on success: "Todo: Buy groceries"
  • All errors prefixed with "Error: " for consistency
  • Each action clears the previous status message at the start (self.status_message = None) before setting the new one

Improvement 3: Empty state message (ui.rs)
#

Problem: When the task list is empty, the table rendered with just a header and empty space — no guidance for the user.

Change: When app.tasks.is_empty(), render a centered Paragraph inside the bordered block: "No tasks yet. Press [a] to add one." in dark gray. This replaces the empty table entirely. The user immediately knows what to do.

Improvement 4: Status message auto-clear (event.rs + app.rs)
#

Problem: Status messages would persist indefinitely until another action replaced them. Navigating the list still showed “Task added: …” from 5 minutes ago.

Change: Added clear_status() method to App. Called from event.rs when the user navigates (j/k/arrows), enters Adding mode, or enters ConfirmDelete mode. Action methods (add, delete, toggle) manage their own status messages. Result: messages appear briefly after an action and disappear on the next navigation — transient feedback, not persistent clutter.

Improvement 5: Visual cursor in Adding mode (ui.rs)
#

Problem: In Adding mode, the user types text but there’s no cursor indicator — just the text appearing.

Change: Added a cyan _ character after the input buffer text in the command bar. Simulates a blinking cursor. The user can see exactly where they’re typing: Title: Buy groc_ [Enter] confirm [Esc] cancel.

Verification
#

  • cargo test: 12 passed, 0 failed
  • cargo clippy -D warnings: 0 errors, 0 warnings
  • cargo fmt --check: clean
  • Fixed deprecated highlight_stylerow_highlight_style (ratatui 0.29 API change)
  • Fixed type inference issue: Span::into() → explicit Line::from(Span::...) for Row cells

[2026-03-21] - Feature: Edit task title — full vertical slice from domain to TUI
#

What: Added the ability to edit an existing task’s title. This required changes across three architectural layers: application (new use case), adapter/tui (new InputMode variant, app method, event handler, UI rendering). The domain layer already had edit_title() implemented but unused — this feature activates it.

Why: Editing is a fundamental CRUD operation that was missing. Without it, if a user made a typo when creating a task, they’d have to delete and recreate it. The domain method edit_title(self, title) -> DomainResult<Self> was already implemented and validated (rejects empty titles, updates modified_at, preserves created_at), so the infrastructure was ready — it just needed a use case and UI to surface it.

How:

Layer 1: Application — edit_task.rs (new file)
#

Created EditTaskCommand, EditTaskUseCase trait, and EditTaskService following the exact same pattern as the other use cases:

  • Command DTO: { task_id: Uuid, new_title: String }
  • Service orchestration: find_by_id → edit_title → save (the standard find-transition-save pattern)
  • Error handling: TaskNotFound if ID doesn’t exist, EmptyTitle if new title is blank (delegated to domain)
  • Registered in use_cases/mod.rs

Layer 2: TUI Adapter
#

InputMode::Editing (app.rs)

  • Added fourth variant to the InputMode enum. The compiler immediately flagged every match that needed updating — this is the exhaustive match benefit we documented earlier.
  • start_editing() method pre-fills input_buffer with the current task title so the user can modify it rather than retyping from scratch.
  • edit_task() method follows the same pattern as add_task(): clear status, execute use case, set feedback message, reset buffer, return to Normal mode, refresh task list.

Event handling (event.rs)

  • Added e keybinding in Normal mode (with !is_empty() guard) that calls app.start_editing()
  • Added handle_editing_mode() function — identical to handle_adding_mode (Enter/Esc/Backspace/Char) but Enter calls edit_task() instead of add_task()
  • This is an example of where the Adding/Editing modes share behavior but are separate modes because they trigger different actions on confirmation

UI rendering (ui.rs)

  • Normal mode command bar now shows [e]dit in cyan (creation category)
  • Editing mode shows Edit: {buffer}_ [Enter] save [Esc] cancel with yellow styling (to visually distinguish from the cyan Adding mode)
  • Adding mode label changed from “Title:” to “New:” to differentiate from editing

Layer 0: Domain — zero changes
#

edit_title() was already implemented and tested implicitly through the domain’s immutable transition pattern. No domain changes required. This is the hexagonal payoff: the domain was ready, we just needed to wire it.

State machine update
#

              ┌──────────┐
    ┌────────▶│  Normal  │◀────────┐
    │         └─┬──┬──┬──┘         │
    │      [a]  │  │  │  [x]      │
    │      [e]  │  │  │            │
    │           ▼  │  ▼            │
    │    ┌───────┐ │ ┌──────────────┐
    ├────│Adding │ │ │ConfirmDelete │──┘
    │    └───────┘ │ └──────────────┘
    │   Esc/Enter  │    Esc/y/n
    │              ▼
    │       ┌─────────┐
    └───────│ Editing │
            └─────────┘
            Esc/Enter

Verification
#

  • cargo test: 12 passed, 0 failed
  • cargo clippy -D warnings: 0 errors, 0 warnings
  • cargo fmt --check: clean after formatting

[2026-03-21] - Feature: Centered popup for text input + event polling fix
#

What: Three improvements committed together as cfedd10:

  1. Replaced inline command bar input with a centered modal popup for Adding and Editing modes
  2. Fixed input lag by switching from blocking event::read() to event::poll() with 16ms timeout
  3. Changed cursor character from _ to (full block) for better visual feedback

Why:

Popup: The command bar was a single row at the bottom of the screen — cramped, easy to miss, and mixing input text with help text. A centered 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. It also separates the input context from the task list, reducing cognitive load.

Event polling: The original event loop used event::read() which blocks indefinitely until an event arrives. The loop was:

draw → read (BLOCK) → 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. The fix uses event::poll(16ms) which returns immediately if no event is ready, allowing the loop to redraw at ~60fps and process keys as soon as they arrive. The 16ms timeout is the standard for TUI apps — responsive enough for typing, light enough on CPU.

Block cursor: The underscore _ cursor is ambiguous — it could be confused with an actual underscore character in the text. The full block is the universally recognized terminal cursor shape, used by most terminal emulators in their default configuration.

How:

Popup implementation (ui.rs) #

  • centered_rect(percent_x, percent_y, area) -> Rect: Helper that calculates a centered rectangle using nested vertical + horizontal Layout splits. Takes percentage dimensions and returns the inner Rect.
  • render_input_popup(frame, app): Renders Clear (wipes background), then a bordered Block with Paragraph containing the input line and help text. Title and color adapt to mode: cyan for “New Task” (Adding), yellow for “Edit Task” (Editing).
  • draw() calls render_input_popup conditionally after rendering the normal UI, so the popup draws on top.
  • render_command simplified: Adding/Editing branches removed (popup handles them), replaced with _ => Line::from(vec![]) catch-all.

Event polling (event.rs)
#

  • Added use std::time::Duration
  • Wrapped event::read() inside if event::poll(Duration::from_millis(16))? — only reads when an event is available

Cursor (ui.rs)
#

  • Single character change: "_""█" in render_input_popup

Commit
#

  • cfedd10feat(tui): add centered popup for text input and fix event polling

[2026-03-21] - Blog post generated from logbook entries
#

What: Transformed the full LOGBOOK.md (11 entries across 2 sessions) into a cohesive technical blog post saved as BLOG_POST.md.

Why: The logbook was designed from the start to serve as raw material for a blog post. The entries captured not just what changed, but why — the design decisions, the trade-offs, the Rust-specific insights. Converting this into a narrative makes the knowledge shareable beyond the repo.

How: The blog post follows a journey structure with 10 sections:

  1. The Starting Point — context on the existing CLI and hexagonal architecture
  2. The Decision — why the migration was feasible and the tool choices (ratatui + crossterm)
  3. Making Invalid States Unrepresentable — the InputMode enum design, boolean vs enum comparison
  4. The Ownership Puzzleclone() analysis on JsonFileTaskRepository
  5. Building the View Layer — ratatui rendering model, layout decisions, the Block gotcha
  6. The Terminal Restore Problem — the “capture result, cleanup, return result” pattern
  7. The Migration Moment of Truth — verification results and the zero-change validation
  8. Polish: The Last Mile — UX improvements batch (colors, feedback, popup, polling)
  9. Adding Edit: The Vertical Slice Test — feature addition as architecture validation
  10. Key Takeaways and Conclusion

Tone: conversational but technical, first-person narrative. Preserves specific code examples and decision rationale from the logbook entries. Connects entries into a flowing story rather than listing them sequentially.