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 Ctui_continuouslibrary: raw mode,tui_printwith^_/^*/^: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— theListenMode::Unixbranch 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=nsigner01 derived=2 │ 2026-08-18 08:00:01 start │
│ socket=@nsigner01 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:
terminal.draw(|f| ui::draw(f, &app))— draws the current screen.crossterm::event::poll(timeout)— non-blocking, 50ms timeout (same as currentpoll_key(50)).- If a key event arrives, handle it (
l/r/d/q/Esc). - If no key within 50ms, call
server.handle_one(&mut dispatcher)to process any pending socket connection (same as current loop). - After handling a request, add to
activity_logand re-draw. - 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
- Add
Appstruct andScreenenum totui.rswith all the state fields currently passed torender_status. - 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):
Paragraphwith info lines in a borderedBlock. - Section 1 (Roles):
TablewithRows fromrole_table.entriesin a borderedBlock. - Section 2 (Activity):
Listfromactivity_log.entries()in a borderedBlock, withList::scrollbaror aScrollbarwidget showing position. Usesactivity_scrollfor the offset. - Section 3 (Commands):
Paragraphwith styled keybinding spans (key letter bold/underlined viaSpan::styled) in a borderedBlockspanning full width.
- Write
App::run()— the event loop:enable_raw_mode()+EnterAlternateScreen(ratatui standard init).terminal.draw(|f| draw(f, self)).event::poll(50ms)→ handle key orserver.handle_one().- On quit:
disable_raw_mode()+LeaveAlternateScreen.
- Connections screen — when
dis pressed, switchcurrent_screentoScreen::Connectionsand draw a full-screenParagraphwith the transport blocks (same content as currentrender_connections). Any key returns toScreen::Main. - Lock screen — when
lis pressed, switch toScreen::Lockwhich shows anInputFieldfor mnemonic re-entry (sameInputFieldwidget as the Unlock screen). On submit, re-derive keys, updatederived_count, add to activity log, return toScreen::Main. - Setup screens with
InputField— implement the Unlock, GenerateMnemonic, RoleWizard, and TransportSelection screens using ratatui rendering andInputFieldfor all text entry. Pre-fill defaults into theInputFieldbuffer (role name, path template). The terminal is initialized at program start, before any prompts — no cooked-moderead_lineanywhere. - Update
main.rs— theListenMode::Unixbranch constructsAppand callsrun(). Movekey_store,alg_key_cache,role_table,mnemonic,activity_log,serverinto theAppstruct. Removeload_mnemonic_tui. - 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. Deletetui_continuous.rsentirely. - 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, pressd/l/r/q, connect withnsigner_client.
What stays the same
ActivityLogstruct 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_connectionsintui.rs.poll_key,TuiKeyenum intui.rs.MAIN_MENU_ITEMS,main_frame,connections_frameintui.rs.read_line_editableintui.rs— replaced byInputFieldwidget.role_wizardintui.rs— replaced byScreen::RoleWizardratatui screen.transport_selectionintui.rs— replaced byScreen::TransportSelection.load_mnemonic_tuiinmain.rs— replaced byScreen::Unlock/Screen::GenerateMnemonic.tui_continuous::init/cleanup/install_resize_handler/resize_pendingcalls inmain.rs.- The
^_/^*/^:hotkey markup system (ratatui uses styled spans instead). - All
println!/print!/read_linecalls in setup flow (replaced by ratatui rendering +InputField).