297 lines
14 KiB
Markdown
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`).
|