Files
signer/plans/ratatui_migration_plan.md

297 lines
14 KiB
Markdown

# Plan: Migrate TUI to ratatui
## Goal
Replace the hand-rolled `tui_continuous` + `tui.rs` rendering with
[`ratatui`](https://github.com/ratatui/ratatui) (already added as a git
submodule and Cargo path dependency). The main status screen becomes a
ratatui app with four sections, and the interactive setup menus (mnemonic,
role wizard, transport selection) become ratatui screens too.
## Current state
- [`src/tui_continuous.rs`](../src/tui_continuous.rs:1) — 916 lines. Hand-rolled
port of the C `tui_continuous` library: raw mode, `tui_print` with `^_`/`^*`/`^:`
markup, `render_top_frame`, `render_table`, `render_menu`, `render_content_screen`,
`anchor_prompt`, `get_key`, `poll_key`, SIGWINCH handler.
- [`src/tui.rs`](../src/tui.rs:1) — 1062 lines. App-level: `ActivityLog`,
`render_status`, `render_connections`, `role_wizard`, `transport_selection`,
`read_line_editable`, `read_line_raw`, `poll_key`, `init`/`cleanup`.
- [`src/main.rs`](../src/main.rs:269) — the `ListenMode::Unix` branch runs the
TUI loop: `init()` → `render_status()` → poll for keys (`l`/`r`/`d`/`q`) →
re-render. Server connections are handled between key polls.
## Target layout — main status screen
Two-column layout: left column has Information (top) and Roles (bottom);
right column has Activity (full height, scrollable). Commands span the
full width at the bottom.
```
┌──────────────────────────────────────────────────────────────────────┐
│ signer v0.0.1 > Main Menu │
├──────────────────────────────────────┬───────────────────────────────┤
│ Information │ Activity (latest first) ▲ │
│ session=unlocked (12 words) │ 2026-08-18 08:00:15 req… │
│ signer=signer01 derived=2 │ 2026-08-18 08:00:01 start │
│ socket=@signer01 transport=unix │ │
│ OTP pad: chksum=abc… offset=128 │ │
├──────────────────────────────────────┤ │
│ Roles │ │
│ Role Purpose Curve │ │
│ ───────────── ─────── ────────── │ │
│ main nostr secp256k1 │ │
│ nostr_range nostr secp256k1 │ │
│ │ ▼ │
├──────────────────────────────────────┴───────────────────────────────┤
│ l lock/reunlock r refresh d display connections q/x quit │
└──────────────────────────────────────────────────────────────────────┘
```
### Layout structure (ratatui `Layout`)
```
Vertical:
[Top frame: title bar] — 3 lines
[Body: horizontal split] — flex
Left column (50%):
[Section 0: Information] — auto height
[Section 1: Roles] — flex
Right column (50%):
[Section 2: Activity] — flex, scrollable
[Section 3: Commands] — 3 lines
```
### Section 0 — Information
A `Block` with title "Information" containing a `Paragraph` of key-value lines:
| Line | Source |
|------|--------|
| `session=<locked\|unlocked> (<N> words)` | `mnemonic.is_loaded()`, `mnemonic.word_count()` |
| `signer=<socket_name>` | `socket_name` |
| `derived=<count>` | `derived_count` |
| `socket=@<socket_name>` | `socket_name` |
| `transport=<unix\|tcp\|http\|qrexec>` | `listen_mode` |
| `OTP pad: chksum=… offset=N/M` | `otp_pad::global_status()` (only if bound) |
### Section 1 — Roles
A `Block` with title "Roles" containing a `Table` with 4 columns
(`Role`, `Purpose`, `Curve`, `Derivation path`) and one row per
`role_table.entries[i]`. The "Derivation path" cell uses the same
display logic as the C `role_table_view_get_cell` (fixed path vs
`%d` with range/set).
### Section 2 — Activity (right column, scrollable)
A `Block` with title "Activity (latest first)" containing a `List` of
`ActivityLog::entries()` (newest first, up to 16). Each entry is a
`ListItem` with the timestamped message. The list is scrollable — when
entries exceed the visible height, a scrollbar is shown (ratatui
`List::scrollbar` or a `Scrollbar` widget overlay). The `App` struct
tracks an `activity_scroll` offset; `Up`/`Down` arrow keys (or `k`/`j`)
scroll the activity list when it has focus.
### Section 3 — Commands
A `Block` with title "Commands" (or a footer bar) containing a row of
keybindings: `l lock/reunlock`, `r refresh`, `d display connections`,
`q/x quit`. Rendered as a `Paragraph` with styled spans (the key letter
underlined/bold, the rest normal) — replaces the `^_` markup approach.
## Architecture
```mermaid
flowchart TD
Main[main.rs ListenMode::Unix branch] --> App[App struct in tui.rs]
App --> Terminal[ratatui Terminal over crossterm]
App --> State[AppState: role_table, mnemonic, activity_log, socket_name, derived_count, transport_mask, otp_status]
App --> Draw[draw function: builds 4 sections]
Draw --> Info[Section 0: Information Block + Paragraph]
Draw --> Roles[Section 1: Roles Block + Table]
Draw --> Activity[Section 2: Activity Block + List]
Draw --> Commands[Section 3: Commands Block + Paragraph]
App --> Events[event loop: crossterm poll + server handle_one]
Events --> KeyHandler[l/r/d/q key handlers]
KeyHandler --> LockScreen[Lock screen: mnemonic re-entry]
KeyHandler --> ConnScreen[Connections screen]
KeyHandler --> Quit[quit]
```
## App struct
```rust
pub struct App {
pub role_table: RoleTable,
pub mnemonic: MnemonicState,
pub key_store: KeyStore,
pub alg_key_cache: AlgorithmKeyCache,
pub activity_log: ActivityLog,
pub socket_name: String,
pub derived_count: usize,
pub transport_mask: u8,
pub server: ServerContext,
pub should_quit: bool,
pub current_screen: Screen,
pub activity_scroll: usize, // scroll offset for the Activity list
}
pub enum Screen {
Main,
Connections,
Lock,
}
```
## Event loop
The main loop changes from "render once, poll key, re-render" to ratatui's
standard event-driven loop:
1. `terminal.draw(|f| ui::draw(f, &app))` — draws the current screen.
2. `crossterm::event::poll(timeout)` — non-blocking, 50ms timeout (same as
current `poll_key(50)`).
3. If a key event arrives, handle it (`l`/`r`/`d`/`q`/`Esc`).
4. If no key within 50ms, call `server.handle_one(&mut dispatcher)` to
process any pending socket connection (same as current loop).
5. After handling a request, add to `activity_log` and re-draw.
6. SIGWINCH is handled automatically by ratatui/crossterm — no manual
`resize_pending()` check needed.
## Setup screens (ratatui input widgets)
The setup screens (mnemonic entry, role wizard, transport selection) also
use ratatui — the terminal is initialized at program start, before any
prompts. Each setup screen is a ratatui screen with input fields that
support pre-filled defaults and full line editing (backspace, arrows,
Ctrl-A/E/U, insert) — replacing the hand-rolled `read_line_editable`.
### Input widget
A reusable `InputField` struct wraps a `String` buffer + cursor position,
rendered as a ratatui `Paragraph` with a cursor block. It handles:
| Key | Action |
|-----|--------|
| Printable char | Insert at cursor |
| Backspace | Delete before cursor |
| Delete | Delete at cursor |
| Left/Right | Move cursor |
| Home/Ctrl-A | Move to start |
| End/Ctrl-E | Move to end |
| Ctrl-U | Clear field |
| Enter | Submit (return field contents) |
When a field has a default value, it is pre-filled into the buffer with the
cursor at the end — the user can backspace to edit or just press Enter to
accept. This replaces `read_line_editable` entirely.
### Screen flow
```mermaid
flowchart TD
Start[Start] --> Unlock[Screen: Unlock<br/>InputField for mnemonic<br/>E or G key to choose mode]
Unlock -->|G| GenShow[Screen: Show generated mnemonic<br/>Press Enter to continue]
GenShow --> Roles
Unlock -->|E or paste| Roles
Roles[Screen: Role preset menu<br/>1-10 selection + InputField for name<br/>Custom: curve menu + InputField for path]
Roles -->|Define another? y| Roles
Roles -->|N or Done| Transport
Transport[Screen: Transport selection<br/>checkbox toggle 1-4, a for all<br/>Enter to confirm]
Transport --> Main[Screen: Main status display]
```
### Screen enum (updated)
```rust
pub enum Screen {
Unlock,
GenerateMnemonic,
RoleWizard,
TransportSelection,
Main,
Connections,
Lock,
}
```
Each setup screen has its own `draw` function and event handler. The
`App::run()` loop dispatches to the appropriate handler based on
`current_screen`. Once setup is complete, `current_screen` transitions to
`Screen::Main` and the main status loop takes over.
## Files to change
| File | Change |
|------|--------|
| [`src/tui.rs`](../src/tui.rs:1) | Full rewrite: `App` struct, `InputField` widget, `Screen` enum, `draw()` for each screen (Unlock, GenerateMnemonic, RoleWizard, TransportSelection, Main, Connections, Lock), event loop. Keep `ActivityLog`. Remove everything else. |
| [`src/main.rs`](../src/main.rs:269) | Move all setup + main-loop logic into `App::run()`. The `ListenMode::Unix` branch just constructs `App` and calls `run()`. Remove `load_mnemonic_tui`. |
| [`src/tui_continuous.rs`](../src/tui_continuous.rs:1) | Delete entirely. |
| [`src/lib.rs`](../src/lib.rs:1) | No change (modules stay the same). |
## Implementation steps
1. **Add `App` struct and `Screen` enum** to `tui.rs` with all the state
fields currently passed to `render_status`.
2. **Write `draw()` function** — builds the two-column layout:
- **Outer vertical split**: title bar (3 lines) / body (flex) / commands (3 lines).
- **Body horizontal split**: left column (50%) / right column (50%).
- **Left column vertical split**: Information (auto height) / Roles (flex).
- **Right column**: Activity list (flex, scrollable with `Scrollbar`).
- Section 0 (Information): `Paragraph` with info lines in a bordered `Block`.
- Section 1 (Roles): `Table` with `Row`s from `role_table.entries` in a bordered `Block`.
- Section 2 (Activity): `List` from `activity_log.entries()` in a bordered `Block`,
with `List::scrollbar` or a `Scrollbar` widget showing position. Uses
`activity_scroll` for the offset.
- Section 3 (Commands): `Paragraph` with styled keybinding spans (key letter
bold/underlined via `Span::styled`) in a bordered `Block` spanning full width.
3. **Write `App::run()`** — the event loop:
- `enable_raw_mode()` + `EnterAlternateScreen` (ratatui standard init).
- `terminal.draw(|f| draw(f, self))`.
- `event::poll(50ms)` → handle key or `server.handle_one()`.
- On quit: `disable_raw_mode()` + `LeaveAlternateScreen`.
4. **Connections screen** — when `d` is pressed, switch `current_screen` to
`Screen::Connections` and draw a full-screen `Paragraph` with the
transport blocks (same content as current `render_connections`). Any key
returns to `Screen::Main`.
5. **Lock screen** — when `l` is pressed, switch to `Screen::Lock` which
shows an `InputField` for mnemonic re-entry (same `InputField` widget as
the Unlock screen). On submit, re-derive keys, update `derived_count`,
add to activity log, return to `Screen::Main`.
6. **Setup screens with `InputField`** — implement the Unlock, GenerateMnemonic,
RoleWizard, and TransportSelection screens using ratatui rendering and
`InputField` for all text entry. Pre-fill defaults into the `InputField`
buffer (role name, path template). The terminal is initialized at program
start, before any prompts — no cooked-mode `read_line` anywhere.
7. **Update `main.rs`** — the `ListenMode::Unix` branch constructs `App` and
calls `run()`. Move `key_store`, `alg_key_cache`, `role_table`, `mnemonic`,
`activity_log`, `server` into the `App` struct. Remove `load_mnemonic_tui`.
8. **Remove old code** — delete `render_status`, `render_connections`,
`poll_key`, `TuiKey`, `MAIN_MENU_ITEMS`, frame helpers, `read_line_editable`,
`role_wizard`, `transport_selection`, `load_mnemonic_tui`. Delete
`tui_continuous.rs` entirely.
9. **Test** — `cargo test` (unit tests don't touch the TUI). Manual test:
start signer, verify setup screens work with InputField, verify 4-section
main screen renders, press `d`/`l`/`r`/`q`, connect with `signer_client`.
## What stays the same
- `ActivityLog` struct and its ring-buffer logic.
- All non-TUI code: `server.rs`, `dispatcher.rs`, `role_table.rs`, etc.
## What gets removed
- `tui_continuous.rs` (entire file, 916 lines) — replaced by ratatui.
- `render_status`, `render_connections` in `tui.rs`.
- `poll_key`, `TuiKey` enum in `tui.rs`.
- `MAIN_MENU_ITEMS`, `main_frame`, `connections_frame` in `tui.rs`.
- `read_line_editable` in `tui.rs` — replaced by `InputField` widget.
- `role_wizard` in `tui.rs` — replaced by `Screen::RoleWizard` ratatui screen.
- `transport_selection` in `tui.rs` — replaced by `Screen::TransportSelection`.
- `load_mnemonic_tui` in `main.rs` — replaced by `Screen::Unlock` / `Screen::GenerateMnemonic`.
- `tui_continuous::init`/`cleanup`/`install_resize_handler`/`resize_pending`
calls in `main.rs`.
- The `^_`/`^*`/`^:` hotkey markup system (ratatui uses styled spans instead).
- All `println!`/`print!`/`read_line` calls in setup flow (replaced by ratatui rendering + `InputField`).