Files
signer/plans/ratatui_migration_plan.md

14 KiB

Plan: Migrate TUI to ratatui

Goal

Replace the hand-rolled tui_continuous + tui.rs rendering with 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 — 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 — 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 — 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

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

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

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)

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 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 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 Delete entirely.
src/lib.rs 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 Rows 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).