Files
signer/plans/port_tui_continuous.md

155 lines
7.8 KiB
Markdown

# Port `tui_continuous` C library to Rust
## Overview
Port the vendored C library `resources/tui_continuous/tui_continuous.c` (536 lines, ~16 public functions) to Rust as a standalone, well-documented module. Keep it separate from the main `tui.rs` so it can be spun out as its own crate later.
## File structure
```
src/
tui_continuous.rs <-- The ported library (new file)
tui.rs <-- Existing app-level TUI code (will call tui_continuous)
```
The existing `tui.rs` will be refactored to call `tui_continuous` primitives instead of using raw `println!`/`tprint!`/`crossterm`.
## C API surface (16 functions)
### Types to port
| C type | Rust equivalent |
|--------|----------------|
| `TuiSize { width, height }` | `pub struct TuiSize { pub width: u16, pub height: u16 }` |
| `TuiMenuItem { label, shortcut }` | `pub struct TuiMenuItem { pub label: &'static str, pub shortcut: char }` |
| `TuiFrame { app_name, app_version, breadcrumb }` | `pub struct TuiFrame { pub app_name: &'static str, pub app_version: &'static str, pub breadcrumb: &'static str }` |
| `TuiMenu { items, count }` | `pub struct TuiMenu<'a> { pub items: &'a [TuiMenuItem] }` |
| `TuiStatus { text }` | `pub struct TuiStatus<'a> { pub text: Option<&'a str> }` |
| `TuiColumn { name, width, right_align }` | `pub struct TuiColumn { pub name: &'static str, pub width: u16, pub right_align: bool }` |
| `TuiTable { columns, get_cell, ... }` | `pub struct TuiTable<'a, F> { pub columns: &'a [TuiColumn], pub row_count: usize, pub get_cell: F }` where `F: Fn(usize, usize, &mut [u8])` |
### Functions to port (in order)
| # | C function | Rust signature | Notes |
|---|-----------|---------------|-------|
| 1 | `tui_terminal_size()` | `pub fn terminal_size() -> TuiSize` | Use `crossterm::terminal::size()` |
| 2 | `tui_install_resize_handler()` | `pub fn install_resize_handler()` | Register SIGWINCH → set `g_resize_pending` flag |
| 3 | `tui_resize_pending()` | `pub fn resize_pending() -> bool` | Check and clear `g_resize_pending` |
| 4 | `tui_init()` | `pub fn init()` | Calls `crossterm::terminal::enable_raw_mode()` |
| 5 | `tui_cleanup()` | `pub fn cleanup()` | Calls `crossterm::terminal::disable_raw_mode()` |
| 6 | `tui_get_key()` | `pub fn get_key() -> Result<TuiKey, Error>` | Returns `TuiKey::Char(c)`, `TuiKey::Resize`, `TuiKey::Eof` |
| 7 | `tui_print()` | `pub fn print(fmt: std::fmt::Arguments)` | Parse `^_`→underline, `^*`→bold, `^:`→reset, `^^`→literal `^` |
| 8 | `tui_clear_continuous()` | `pub fn clear_continuous(term_height: u16)` | ANSI escape sequence to clear scrollback region |
| 9 | `tui_render_top_frame()` | `pub fn render_top_frame(frame: &TuiFrame)` | Draw `====` header with centered title + breadcrumb |
| 10 | `tui_menu_left_col()` | `pub fn menu_left_col(frame: &TuiFrame) -> u16` | Compute left column for centered menu |
| 11 | `tui_render_menu()` | `pub fn render_menu(menu: &TuiMenu, left_col: u16)` | Render each menu item via `tui_print()` |
| 12 | `tui_render_status()` | `pub fn render_status_line(status: &TuiStatus)` | Print status text if non-empty |
| 13 | `tui_anchor_prompt()` | `pub fn anchor_prompt(filler_lines: u16, left_col: u16)` | Fill blank lines and position cursor |
| 14 | `tui_render_content_screen()` | `pub fn render_content_screen(frame: &TuiFrame, title: Option<&str>)` | `clear_continuous` + `render_top_frame` + optional title |
| 15 | `tui_render_screen()` | `pub fn render_screen(frame: &TuiFrame, menu: Option<&TuiMenu>, status: Option<&TuiStatus>)` | Full screen layout with filler lines |
| 16 | `tui_render_table()` | `pub fn render_table(table: &TuiTable)` | Column-aligned table with compact mode fallback |
| 17 | `tui_read_line()` | `pub fn read_line(buf: &mut [u8]) -> Result<usize, Error>` | Read line with `fgets`-like semantics (cooked mode) |
| 18 | `tui_is_escape_input()` | `pub fn is_escape_input(input: &str) -> bool` | Check for `q`/`x`/`exit`/`quit`/`esc` |
| 19 | `tui_menu_match_key()` | `pub fn menu_match_key(menu: &TuiMenu, input: &str) -> Option<usize>` | Match single-char input to menu item shortcut |
| 20 | `tui_compute_unique_prefixes()` | `pub fn compute_unique_prefixes(ids: &[&str]) -> Vec<usize>` | Compute minimal unique prefix lengths |
| 21 | `tui_confirm()` | `pub fn confirm(prompt: &str) -> bool` | `[y/n]` with single-key in raw mode, line fallback |
| 22 | `tui_prompt_default()` | `pub fn prompt_default(prompt: &str, default: &str, out: &mut String) -> io::Result<()>` | Prompt with default value |
| 23 | `tui_press_enter()` | `pub fn press_enter(message: Option<&str>)` | Wait for any key with `tui_get_key()` |
| 24 | `tui_has_stdin_pipe()` | `pub fn has_stdin_pipe() -> bool` | Check `isatty(STDIN_FILENO)` via libc |
## Implementation details
### Hotkey markup (`tui_print`)
The `^_X^` → `\033[4mX\033[0m` (underline) and `^*X^` → `\033[1mX\033[0m` (bold) markup is central to how the C TUI renders labels. The Rust `tui_print` must:
1. Write `\r\n` at end (raw mode needs explicit CR)
2. Parse `^_` / `^*` / `^:` / `^^` sequences
3. Use `\x1b[4m` / `\x1b[1m` / `\x1b[0m` ANSI escape codes
### SIGWINCH handling
The `g_resize_pending` static flag is set by a signal handler. In Rust, use `std::sync::atomic::AtomicBool`:
```rust
use std::sync::atomic::{AtomicBool, Ordering};
static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);
extern "C" fn handle_sigwinch(_: i32) {
RESIZE_PENDING.store(true, Ordering::SeqCst);
}
```
### `tui_get_key()` in Rust
```rust
pub enum TuiKey {
Char(char),
Eof,
Resize,
}
pub fn get_key() -> io::Result<TuiKey> {
use crossterm::event::{read, Event, KeyCode, KeyEvent};
// Check for SIGWINCH first
if RESIZE_PENDING.swap(false, Ordering::SeqCst) {
return Ok(TuiKey::Resize);
}
// Block on crossterm::event::read()
match read()? {
Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) => Ok(TuiKey::Char(c)),
Event::Resize(..) => Ok(TuiKey::Resize),
_ => Ok(TuiKey::Eof),
}
}
```
### `tui_render_table()` with callbacks
The C version uses a callback `get_cell(row, col, out, out_size, user_data)`. In Rust, use a closure:
```rust
pub struct TuiTable<'a, F: Fn(usize, usize) -> String> {
pub columns: &'a [TuiColumn],
pub row_count: usize,
pub get_cell: F,
}
```
## Refactoring main.rs
After the port is done, update `main.rs` to:
1. Remove `tui::init()`/`cleanup()` calls — use `tui_continuous::init()`/`cleanup()`
2. Use `tui_continuous::render_top_frame()` and `tui_continuous::render_table()` for the status screen
3. Use `tui_continuous::get_key()` instead of `tui::poll_key()`
4. Add `tui_continuous::install_resize_handler()` at startup
5. Add `tui_continuous::resize_pending()` check in the main loop
6. Add 'r' (refresh) and 'l' (lock/reunlock) key handlers
7. Add activity log
## Dependencies
No new dependencies needed. Uses:
- `crossterm` (already in Cargo.toml) for terminal size, raw mode
- `libc` (already in Cargo.toml) for SIGWINCH, isatty
- `std::sync::atomic` for resize flag
## Phase plan
### Phase 1: Core types and utilities
- `TuiSize`, `TuiMenuItem`, `TuiFrame`, `TuiMenu`, `TuiStatus`, `TuiColumn`, `TuiTable` types
- `terminal_size()`, `install_resize_handler()`, `resize_pending()`
- `init()`, `cleanup()`, `get_key()`
### Phase 2: Print and rendering
- `print()` with hotkey markup
- `clear_continuous()`, `render_top_frame()`, `menu_left_col()`
- `render_menu()`, `render_status_line()`, `anchor_prompt()`
- `render_content_screen()`, `render_screen()`
### Phase 3: Table rendering
- `render_table()` with compact mode fallback
- `compute_unique_prefixes()`
### Phase 4: Input helpers
- `read_line()`, `is_escape_input()`, `menu_match_key()`
- `confirm()`, `prompt_default()`, `press_enter()`, `has_stdin_pipe()`
### Phase 5: Integration
- Update `main.rs` to use `tui_continuous`
- Add activity log, lock/reunlock, refresh, SIGWINCH handling
- Remove or reduce `tui.rs` to just the high-level app screens