2026-09-10 01:54:19 -04:00
|
|
|
use std::io;
|
2026-09-10 02:43:45 -04:00
|
|
|
use std::sync::Arc;
|
2026-09-10 01:54:19 -04:00
|
|
|
|
|
|
|
|
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
use ratatui::layout::{Constraint, Layout, Rect};
|
|
|
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
|
|
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
|
2026-09-10 01:54:19 -04:00
|
|
|
use ratatui::{DefaultTerminal, Frame};
|
|
|
|
|
|
2026-09-10 02:43:45 -04:00
|
|
|
use crate::node::Node;
|
2026-09-10 01:54:19 -04:00
|
|
|
use crate::qr::{self, QrPayload};
|
2026-09-10 02:43:45 -04:00
|
|
|
use crate::store::Friend;
|
2026-09-10 01:54:19 -04:00
|
|
|
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
const C_ACCENT: Color = Color::Rgb(232, 165, 75);
|
|
|
|
|
const C_TEXT: Color = Color::Rgb(196, 214, 176);
|
|
|
|
|
const C_DIM: Color = Color::Rgb(98, 110, 92);
|
|
|
|
|
const C_ALERT: Color = Color::Rgb(220, 80, 70);
|
|
|
|
|
const C_STALE: Color = Color::Rgb(210, 180, 80);
|
|
|
|
|
const C_HL_BG: Color = Color::Rgb(48, 42, 28);
|
|
|
|
|
const C_BORDER: Color = Color::Rgb(72, 84, 64);
|
|
|
|
|
|
2026-09-10 02:07:40 -04:00
|
|
|
pub fn fingerprint_mismatch_banner() -> &'static str {
|
|
|
|
|
"fingerprint mismatch"
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:43:45 -04:00
|
|
|
pub fn rotate_screen_text() -> &'static str {
|
|
|
|
|
"Rotate onion address?\n\
|
|
|
|
|
Your identity key stays the same.\n\
|
|
|
|
|
Online friends get a signed location update.\n\
|
|
|
|
|
Offline friends CANNOT find you until they rescan your QR.\n\
|
|
|
|
|
Type ROTATE to confirm Esc to cancel"
|
|
|
|
|
}
|
|
|
|
|
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
/// Full Main-screen wordmark. Every line is <= 80 ASCII characters.
|
|
|
|
|
pub fn wordmark_banner() -> &'static str {
|
|
|
|
|
"\
|
|
|
|
|
.-~~-. .-~~-. .-~~-. ONIONWIRE\n\
|
|
|
|
|
( o )( o )( o ) identity = pubkey onion = locator\n\
|
|
|
|
|
`-..-' `-..-' `-..-' tor messenger no server"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One-line fallback when the terminal is narrower than the wordmark.
|
|
|
|
|
pub fn compact_banner() -> &'static str {
|
|
|
|
|
"ONIONWIRE (o) tor"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn onion_glyph() -> &'static str {
|
|
|
|
|
"\
|
|
|
|
|
.-.\n\
|
|
|
|
|
( o )\n\
|
|
|
|
|
'-'"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn help_overlay_text() -> &'static str {
|
|
|
|
|
"\
|
|
|
|
|
OnionWire keys\n\
|
|
|
|
|
\n\
|
|
|
|
|
Tab / Shift-Tab cycle roster, chat, composer\n\
|
|
|
|
|
1 / 2 / 3 focus roster / chat / composer\n\
|
|
|
|
|
j k or Up Down move or scroll focused pane\n\
|
|
|
|
|
g / G jump to top / bottom\n\
|
|
|
|
|
Enter /wipe /wipe-all from composer\n\
|
|
|
|
|
Esc close overlay, go back, clear composer\n\
|
|
|
|
|
F2 share QR\n\
|
|
|
|
|
F3 paste a friend QR\n\
|
|
|
|
|
F4 rotate onion locator\n\
|
|
|
|
|
? this help\n\
|
|
|
|
|
Ctrl-Q quit\n\
|
|
|
|
|
\n\
|
|
|
|
|
Esc closes this overlay"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn banner_for_width(width: u16) -> &'static str {
|
|
|
|
|
let full = wordmark_banner();
|
|
|
|
|
let max_line = full.lines().map(|l| l.chars().count()).max().unwrap_or(0);
|
|
|
|
|
if (width as usize) >= max_line {
|
|
|
|
|
full
|
|
|
|
|
} else {
|
|
|
|
|
compact_banner()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum Pane {
|
|
|
|
|
Roster,
|
|
|
|
|
Chat,
|
|
|
|
|
Composer,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Pane {
|
|
|
|
|
pub fn next(self) -> Self {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Roster => Self::Chat,
|
|
|
|
|
Self::Chat => Self::Composer,
|
|
|
|
|
Self::Composer => Self::Roster,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn prev(self) -> Self {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Roster => Self::Composer,
|
|
|
|
|
Self::Chat => Self::Roster,
|
|
|
|
|
Self::Composer => Self::Chat,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:49:42 -04:00
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum WipeKind {
|
|
|
|
|
Messages,
|
|
|
|
|
All,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum WipeDecision {
|
|
|
|
|
Pending,
|
|
|
|
|
Confirm,
|
|
|
|
|
Cancel,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum AppExit {
|
|
|
|
|
Quit,
|
|
|
|
|
WipeAll,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn parse_slash(raw: &str) -> Option<WipeKind> {
|
|
|
|
|
match raw.trim() {
|
|
|
|
|
"/wipe" => Some(WipeKind::Messages),
|
|
|
|
|
"/wipe-all" => Some(WipeKind::All),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn wipe_screen_text(kind: WipeKind) -> &'static str {
|
|
|
|
|
match kind {
|
|
|
|
|
WipeKind::Messages => {
|
|
|
|
|
"Wipe message log?\n\
|
|
|
|
|
Identity key and friends stay.\n\
|
|
|
|
|
Type WIPE to confirm Esc to cancel"
|
|
|
|
|
}
|
|
|
|
|
WipeKind::All => {
|
|
|
|
|
"Wipe ALL local data?\n\
|
|
|
|
|
This deletes your identity key. You become a new person.\n\
|
|
|
|
|
Type WIPEALL to confirm Esc to cancel"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub struct WipePrompt {
|
|
|
|
|
expected: &'static str,
|
|
|
|
|
buf: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl WipePrompt {
|
|
|
|
|
pub fn messages() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
expected: "WIPE",
|
|
|
|
|
buf: String::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn all() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
expected: "WIPEALL",
|
|
|
|
|
buf: String::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn on_esc(&self) -> WipeDecision {
|
|
|
|
|
WipeDecision::Cancel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn on_char(&mut self, c: char) -> WipeDecision {
|
|
|
|
|
if c == '\n' {
|
|
|
|
|
return WipeDecision::Pending;
|
|
|
|
|
}
|
|
|
|
|
if c == '\u{8}' {
|
|
|
|
|
self.buf.pop();
|
|
|
|
|
return WipeDecision::Pending;
|
|
|
|
|
}
|
|
|
|
|
if !c.is_ascii_alphabetic() {
|
|
|
|
|
return WipeDecision::Pending;
|
|
|
|
|
}
|
|
|
|
|
self.buf.push(c);
|
|
|
|
|
if self.buf == self.expected {
|
|
|
|
|
WipeDecision::Confirm
|
|
|
|
|
} else {
|
|
|
|
|
WipeDecision::Pending
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn typed(&self) -> &str {
|
|
|
|
|
&self.buf
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:43:45 -04:00
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum RotateDecision {
|
|
|
|
|
Pending,
|
|
|
|
|
Confirm,
|
|
|
|
|
Cancel,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
pub struct RotatePrompt {
|
|
|
|
|
buf: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RotatePrompt {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self::default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn on_esc(&self) -> RotateDecision {
|
|
|
|
|
RotateDecision::Cancel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn on_char(&mut self, c: char) -> RotateDecision {
|
|
|
|
|
if c == '\n' {
|
|
|
|
|
return RotateDecision::Pending;
|
|
|
|
|
}
|
|
|
|
|
if c == '\u{8}' {
|
|
|
|
|
self.buf.pop();
|
|
|
|
|
return RotateDecision::Pending;
|
|
|
|
|
}
|
|
|
|
|
if !c.is_ascii_alphabetic() {
|
|
|
|
|
return RotateDecision::Pending;
|
|
|
|
|
}
|
|
|
|
|
self.buf.push(c);
|
|
|
|
|
if self.buf == "ROTATE" {
|
|
|
|
|
RotateDecision::Confirm
|
|
|
|
|
} else {
|
|
|
|
|
RotateDecision::Pending
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn typed(&self) -> &str {
|
|
|
|
|
&self.buf
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:49:42 -04:00
|
|
|
pub fn run(node: Arc<Node>, rt: tokio::runtime::Handle) -> Result<AppExit, String> {
|
2026-09-10 02:43:45 -04:00
|
|
|
let mut app = App::new(node, rt)?;
|
2026-09-10 01:54:19 -04:00
|
|
|
let mut terminal = ratatui::init();
|
|
|
|
|
let result = app.run(&mut terminal);
|
|
|
|
|
ratatui::restore();
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct App {
|
2026-09-10 02:43:45 -04:00
|
|
|
node: Arc<Node>,
|
|
|
|
|
rt: tokio::runtime::Handle,
|
2026-09-10 01:54:19 -04:00
|
|
|
screen: Screen,
|
|
|
|
|
friends: Vec<Friend>,
|
|
|
|
|
selected: usize,
|
|
|
|
|
me_fp: String,
|
|
|
|
|
me_onion: String,
|
2026-09-10 02:07:40 -04:00
|
|
|
alert: Option<String>,
|
2026-09-10 02:43:45 -04:00
|
|
|
status_note: Option<String>,
|
2026-09-10 02:49:42 -04:00
|
|
|
composer: String,
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
focus: Pane,
|
|
|
|
|
help_open: bool,
|
|
|
|
|
chat_scroll: u16,
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum Screen {
|
|
|
|
|
Main,
|
|
|
|
|
Share { art: String, payload: String },
|
|
|
|
|
Paste { buf: String, err: Option<String> },
|
|
|
|
|
Approve { payload: QrPayload },
|
2026-09-10 02:43:45 -04:00
|
|
|
Rotate { prompt: RotatePrompt },
|
2026-09-10 02:49:42 -04:00
|
|
|
Wipe { kind: WipeKind, prompt: WipePrompt },
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl App {
|
2026-09-10 02:43:45 -04:00
|
|
|
fn new(node: Arc<Node>, rt: tokio::runtime::Handle) -> Result<Self, String> {
|
|
|
|
|
let friends = node.list_friends()?;
|
2026-09-10 01:54:19 -04:00
|
|
|
Ok(Self {
|
2026-09-10 02:43:45 -04:00
|
|
|
me_fp: to_hex(&node.identity_pk()),
|
|
|
|
|
me_onion: node.onion(),
|
|
|
|
|
node,
|
|
|
|
|
rt,
|
2026-09-10 01:54:19 -04:00
|
|
|
screen: Screen::Main,
|
|
|
|
|
friends,
|
|
|
|
|
selected: 0,
|
2026-09-10 02:07:40 -04:00
|
|
|
alert: None,
|
2026-09-10 02:43:45 -04:00
|
|
|
status_note: None,
|
2026-09-10 02:49:42 -04:00
|
|
|
composer: String::new(),
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
focus: Pane::Composer,
|
|
|
|
|
help_open: false,
|
|
|
|
|
chat_scroll: 0,
|
2026-09-10 01:54:19 -04:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:49:42 -04:00
|
|
|
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<AppExit, String> {
|
2026-09-10 01:54:19 -04:00
|
|
|
loop {
|
|
|
|
|
terminal.draw(|f| self.draw(f)).map_err(|e| e.to_string())?;
|
|
|
|
|
let Event::Key(key) = event::read().map_err(io_err)? else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if key.kind != KeyEventKind::Press {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
2026-09-10 02:49:42 -04:00
|
|
|
return Ok(AppExit::Quit);
|
|
|
|
|
}
|
|
|
|
|
if let Some(exit) = self.handle_key(key)? {
|
|
|
|
|
return Ok(exit);
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:49:42 -04:00
|
|
|
fn handle_key(&mut self, key: KeyEvent) -> Result<Option<AppExit>, String> {
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
if self.help_open {
|
|
|
|
|
match key.code {
|
|
|
|
|
KeyCode::Esc | KeyCode::Char('?') => self.help_open = false,
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
if key.code == KeyCode::Char('?') && !matches!(self.screen, Screen::Paste { .. }) {
|
|
|
|
|
self.help_open = true;
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:49:42 -04:00
|
|
|
let mut wipe_confirm = None;
|
2026-09-10 01:54:19 -04:00
|
|
|
match &mut self.screen {
|
|
|
|
|
Screen::Main => match key.code {
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
KeyCode::Tab => self.focus = self.focus.next(),
|
|
|
|
|
KeyCode::BackTab => self.focus = self.focus.prev(),
|
|
|
|
|
KeyCode::Up => self.nav_up(),
|
|
|
|
|
KeyCode::Down => self.nav_down(),
|
2026-09-10 01:54:19 -04:00
|
|
|
KeyCode::F(2) => self.open_share()?,
|
|
|
|
|
KeyCode::F(3) => {
|
|
|
|
|
self.screen = Screen::Paste {
|
|
|
|
|
buf: String::new(),
|
|
|
|
|
err: None,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-09-10 02:43:45 -04:00
|
|
|
KeyCode::F(4) => {
|
|
|
|
|
self.screen = Screen::Rotate {
|
|
|
|
|
prompt: RotatePrompt::new(),
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-09-10 02:49:42 -04:00
|
|
|
KeyCode::Esc => self.composer.clear(),
|
|
|
|
|
KeyCode::Backspace => {
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
if self.focus == Pane::Composer {
|
|
|
|
|
self.composer.pop();
|
|
|
|
|
}
|
2026-09-10 02:49:42 -04:00
|
|
|
}
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
if let Some(kind) = parse_slash(&self.composer) {
|
|
|
|
|
self.composer.clear();
|
|
|
|
|
let prompt = match kind {
|
|
|
|
|
WipeKind::Messages => WipePrompt::messages(),
|
|
|
|
|
WipeKind::All => WipePrompt::all(),
|
|
|
|
|
};
|
|
|
|
|
self.screen = Screen::Wipe { kind, prompt };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
self.handle_main_char(c);
|
2026-09-10 02:49:42 -04:00
|
|
|
}
|
2026-09-10 01:54:19 -04:00
|
|
|
_ => {}
|
|
|
|
|
},
|
|
|
|
|
Screen::Share { .. } => {
|
|
|
|
|
if key.code == KeyCode::Esc {
|
|
|
|
|
self.screen = Screen::Main;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Screen::Paste { buf, err } => match key.code {
|
|
|
|
|
KeyCode::Esc => self.screen = Screen::Main,
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
let raw = buf.clone();
|
|
|
|
|
self.submit_paste(&raw)?;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Backspace => {
|
|
|
|
|
buf.pop();
|
|
|
|
|
*err = None;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
|
|
|
buf.push(c);
|
|
|
|
|
*err = None;
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
|
|
|
|
Screen::Approve { payload } => match key.code {
|
|
|
|
|
KeyCode::Char('y') | KeyCode::Enter => {
|
|
|
|
|
let p = payload.clone();
|
|
|
|
|
self.accept_friend(&p)?;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Char('n') | KeyCode::Esc => self.screen = Screen::Main,
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
2026-09-10 02:43:45 -04:00
|
|
|
Screen::Rotate { prompt } => match key.code {
|
|
|
|
|
KeyCode::Esc => self.screen = Screen::Main,
|
|
|
|
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
|
|
|
if prompt.on_char(c) == RotateDecision::Confirm {
|
|
|
|
|
self.confirm_rotate()?;
|
|
|
|
|
}
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
2026-09-10 02:43:45 -04:00
|
|
|
KeyCode::Backspace => {
|
|
|
|
|
prompt.on_char('\u{8}');
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
2026-09-10 02:49:42 -04:00
|
|
|
Screen::Wipe { kind, prompt } => {
|
|
|
|
|
let kind = *kind;
|
|
|
|
|
match key.code {
|
|
|
|
|
KeyCode::Esc => self.screen = Screen::Main,
|
|
|
|
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
|
|
|
if prompt.on_char(c) == WipeDecision::Confirm {
|
|
|
|
|
wipe_confirm = Some(kind);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Backspace => {
|
|
|
|
|
prompt.on_char('\u{8}');
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(kind) = wipe_confirm {
|
|
|
|
|
return self.confirm_wipe(kind);
|
|
|
|
|
}
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
fn handle_main_char(&mut self, c: char) {
|
|
|
|
|
if self.focus != Pane::Composer {
|
|
|
|
|
match c {
|
|
|
|
|
'1' => self.focus = Pane::Roster,
|
|
|
|
|
'2' => self.focus = Pane::Chat,
|
|
|
|
|
'3' => self.focus = Pane::Composer,
|
|
|
|
|
'j' => self.nav_down(),
|
|
|
|
|
'k' => self.nav_up(),
|
|
|
|
|
'g' => self.nav_top(),
|
|
|
|
|
'G' => self.nav_bottom(),
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
self.composer.push(c);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn nav_up(&mut self) {
|
|
|
|
|
match self.focus {
|
|
|
|
|
Pane::Roster => {
|
|
|
|
|
self.selected = self.selected.saturating_sub(1);
|
|
|
|
|
self.chat_scroll = u16::MAX;
|
|
|
|
|
}
|
|
|
|
|
Pane::Chat => {
|
|
|
|
|
self.chat_scroll = self.chat_scroll.saturating_sub(1);
|
|
|
|
|
}
|
|
|
|
|
Pane::Composer => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn nav_down(&mut self) {
|
|
|
|
|
match self.focus {
|
|
|
|
|
Pane::Roster => {
|
|
|
|
|
if !self.friends.is_empty() {
|
|
|
|
|
self.selected = (self.selected + 1).min(self.friends.len() - 1);
|
|
|
|
|
self.chat_scroll = u16::MAX;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Pane::Chat => {
|
|
|
|
|
self.chat_scroll = self.chat_scroll.saturating_add(1);
|
|
|
|
|
}
|
|
|
|
|
Pane::Composer => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn nav_top(&mut self) {
|
|
|
|
|
match self.focus {
|
|
|
|
|
Pane::Roster => {
|
|
|
|
|
self.selected = 0;
|
|
|
|
|
self.chat_scroll = u16::MAX;
|
|
|
|
|
}
|
|
|
|
|
Pane::Chat => self.chat_scroll = 0,
|
|
|
|
|
Pane::Composer => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn nav_bottom(&mut self) {
|
|
|
|
|
match self.focus {
|
|
|
|
|
Pane::Roster => {
|
|
|
|
|
if !self.friends.is_empty() {
|
|
|
|
|
self.selected = self.friends.len() - 1;
|
|
|
|
|
self.chat_scroll = u16::MAX;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Pane::Chat => self.chat_scroll = u16::MAX,
|
|
|
|
|
Pane::Composer => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:49:42 -04:00
|
|
|
fn confirm_wipe(&mut self, kind: WipeKind) -> Result<Option<AppExit>, String> {
|
|
|
|
|
match kind {
|
|
|
|
|
WipeKind::Messages => {
|
|
|
|
|
self.node.wipe_messages()?;
|
|
|
|
|
self.status_note = Some("wiped messages".into());
|
|
|
|
|
self.screen = Screen::Main;
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
WipeKind::All => Ok(Some(AppExit::WipeAll)),
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:43:45 -04:00
|
|
|
fn confirm_rotate(&mut self) -> Result<(), String> {
|
|
|
|
|
let node = Arc::clone(&self.node);
|
|
|
|
|
match self.rt.block_on(node.rotate()) {
|
|
|
|
|
Ok(outcome) => {
|
|
|
|
|
self.me_onion = node.onion();
|
|
|
|
|
self.status_note = Some(format!(
|
|
|
|
|
"rotated · notified {}/{} friends",
|
|
|
|
|
outcome.notified, outcome.friends
|
|
|
|
|
));
|
|
|
|
|
self.open_share()
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
self.alert = Some(e);
|
|
|
|
|
self.screen = Screen::Main;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:54:19 -04:00
|
|
|
fn open_share(&mut self) -> Result<(), String> {
|
2026-09-10 02:43:45 -04:00
|
|
|
let payload = self.node.qr_payload()?;
|
2026-09-10 01:54:19 -04:00
|
|
|
let art = qr::render_unicode(&payload).map_err(|e| e.to_string())?;
|
|
|
|
|
self.screen = Screen::Share { art, payload };
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn submit_paste(&mut self, raw: &str) -> Result<(), String> {
|
|
|
|
|
match qr::decode(raw.trim()) {
|
|
|
|
|
Err(e) => {
|
|
|
|
|
self.screen = Screen::Paste {
|
|
|
|
|
buf: raw.to_string(),
|
|
|
|
|
err: Some(e.to_string()),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
Ok(payload) => {
|
2026-09-10 02:43:45 -04:00
|
|
|
let known = self.node.get_friend(&payload.pubkey)?.is_some();
|
2026-09-10 01:54:19 -04:00
|
|
|
if known {
|
|
|
|
|
self.accept_friend(&payload)?;
|
|
|
|
|
} else {
|
|
|
|
|
self.screen = Screen::Approve { payload };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn accept_friend(&mut self, payload: &QrPayload) -> Result<(), String> {
|
2026-09-10 02:43:45 -04:00
|
|
|
self.node.add_friend_payload(payload)?;
|
2026-09-10 01:54:19 -04:00
|
|
|
self.reload_friends()?;
|
|
|
|
|
self.screen = Screen::Main;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn reload_friends(&mut self) -> Result<(), String> {
|
2026-09-10 02:43:45 -04:00
|
|
|
self.friends = self.node.list_friends()?;
|
2026-09-10 01:54:19 -04:00
|
|
|
if self.friends.is_empty() {
|
|
|
|
|
self.selected = 0;
|
|
|
|
|
} else {
|
|
|
|
|
self.selected = self.selected.min(self.friends.len() - 1);
|
|
|
|
|
}
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
self.chat_scroll = u16::MAX;
|
2026-09-10 01:54:19 -04:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn draw(&self, f: &mut Frame) {
|
|
|
|
|
match &self.screen {
|
|
|
|
|
Screen::Main => self.draw_main(f),
|
|
|
|
|
Screen::Share { art, payload } => draw_share(f, art, payload),
|
|
|
|
|
Screen::Paste { buf, err } => draw_paste(f, buf, err.as_deref()),
|
|
|
|
|
Screen::Approve { payload } => draw_approve(f, payload),
|
2026-09-10 02:43:45 -04:00
|
|
|
Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()),
|
2026-09-10 02:49:42 -04:00
|
|
|
Screen::Wipe { kind, prompt } => draw_wipe(f, *kind, prompt.typed()),
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
if self.help_open {
|
|
|
|
|
draw_help_overlay(f);
|
|
|
|
|
}
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn draw_main(&self, f: &mut Frame) {
|
|
|
|
|
let area = f.area();
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
let banner = banner_for_width(area.width);
|
|
|
|
|
let banner_h = banner.lines().count() as u16;
|
|
|
|
|
let alert_h = u16::from(self.alert.is_some());
|
2026-09-10 02:49:42 -04:00
|
|
|
let cols = Layout::vertical([
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
Constraint::Length(banner_h),
|
|
|
|
|
Constraint::Length(alert_h),
|
|
|
|
|
Constraint::Min(3),
|
|
|
|
|
Constraint::Length(3),
|
2026-09-10 02:49:42 -04:00
|
|
|
Constraint::Length(1),
|
|
|
|
|
])
|
|
|
|
|
.split(area);
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
|
|
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(banner).style(Style::default().fg(C_ACCENT)),
|
|
|
|
|
cols[0],
|
|
|
|
|
);
|
2026-09-10 02:07:40 -04:00
|
|
|
if let Some(msg) = &self.alert {
|
|
|
|
|
f.render_widget(
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
Paragraph::new(msg.as_str()).style(Style::default().fg(C_ALERT)),
|
|
|
|
|
cols[1],
|
2026-09-10 02:07:40 -04:00
|
|
|
);
|
|
|
|
|
}
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
|
2026-09-10 01:54:19 -04:00
|
|
|
let panes =
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
Layout::horizontal([Constraint::Percentage(36), Constraint::Min(10)]).split(cols[2]);
|
|
|
|
|
|
|
|
|
|
let stale_n = self.friends.iter().filter(|fr| !fr.last_connect_ok).count();
|
|
|
|
|
let roster_title = if stale_n > 0 {
|
|
|
|
|
format!("roster {} {stale_n} stale", self.friends.len())
|
|
|
|
|
} else {
|
|
|
|
|
format!("roster {}", self.friends.len())
|
|
|
|
|
};
|
2026-09-10 01:54:19 -04:00
|
|
|
|
|
|
|
|
let items: Vec<ListItem> = self
|
|
|
|
|
.friends
|
|
|
|
|
.iter()
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
.map(|friend| {
|
2026-09-10 01:54:19 -04:00
|
|
|
let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint);
|
|
|
|
|
let fp = truncate_fp(&friend.fingerprint);
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
let mut line = format!("{name} {fp}");
|
|
|
|
|
if !friend.last_connect_ok {
|
|
|
|
|
line.push_str(" stale");
|
|
|
|
|
}
|
|
|
|
|
let style = if friend.last_connect_ok {
|
|
|
|
|
Style::default().fg(C_TEXT)
|
|
|
|
|
} else {
|
|
|
|
|
Style::default().fg(C_STALE)
|
|
|
|
|
};
|
|
|
|
|
ListItem::new(line).style(style)
|
2026-09-10 01:54:19 -04:00
|
|
|
})
|
|
|
|
|
.collect();
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
let roster = List::new(items)
|
|
|
|
|
.highlight_style(
|
|
|
|
|
Style::default()
|
|
|
|
|
.bg(C_HL_BG)
|
|
|
|
|
.fg(C_ACCENT)
|
|
|
|
|
.add_modifier(Modifier::BOLD),
|
|
|
|
|
)
|
|
|
|
|
.highlight_symbol("> ")
|
|
|
|
|
.block(self.pane_block(&roster_title, Pane::Roster));
|
|
|
|
|
let mut roster_state = ListState::default();
|
|
|
|
|
if !self.friends.is_empty() {
|
|
|
|
|
roster_state.select(Some(self.selected));
|
|
|
|
|
}
|
|
|
|
|
f.render_stateful_widget(roster, panes[0], &mut roster_state);
|
|
|
|
|
|
|
|
|
|
let chat_lines = self.chat_lines();
|
|
|
|
|
let inner_h = panes[1].height.saturating_sub(2);
|
|
|
|
|
let nlines = chat_lines.len();
|
|
|
|
|
let max_scroll = nlines.saturating_sub(inner_h as usize) as u16;
|
|
|
|
|
let scroll = self.chat_scroll.min(max_scroll);
|
|
|
|
|
let overflow = nlines > inner_h as usize && inner_h > 0;
|
|
|
|
|
let chat_title = {
|
|
|
|
|
let name = self.friends.get(self.selected).map(|friend| {
|
|
|
|
|
friend
|
|
|
|
|
.petname
|
|
|
|
|
.as_deref()
|
|
|
|
|
.unwrap_or(&friend.fingerprint)
|
|
|
|
|
.to_string()
|
|
|
|
|
});
|
|
|
|
|
let mut t = match name {
|
|
|
|
|
Some(n) => format!("chat {n}"),
|
|
|
|
|
None => "chat".to_string(),
|
|
|
|
|
};
|
|
|
|
|
if overflow {
|
|
|
|
|
let end = (scroll as usize + inner_h as usize).min(nlines);
|
|
|
|
|
t.push_str(&format!(" {}-{end}/{nlines}", scroll as usize + 1));
|
|
|
|
|
}
|
|
|
|
|
t
|
2026-09-10 01:54:19 -04:00
|
|
|
};
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
let chat_text = chat_lines.join("\n");
|
2026-09-10 01:54:19 -04:00
|
|
|
let chat = Paragraph::new(chat_text)
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
.style(Style::default().fg(C_TEXT))
|
2026-09-10 01:54:19 -04:00
|
|
|
.wrap(Wrap { trim: false })
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
.scroll((scroll, 0))
|
|
|
|
|
.block(self.pane_block(&chat_title, Pane::Chat));
|
2026-09-10 01:54:19 -04:00
|
|
|
f.render_widget(chat, panes[1]);
|
|
|
|
|
|
2026-09-10 02:49:42 -04:00
|
|
|
let cmd = if self.composer.is_empty() {
|
|
|
|
|
"/wipe /wipe-all".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
self.composer.clone()
|
|
|
|
|
};
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
let cmd_style = if self.composer.is_empty() {
|
|
|
|
|
Style::default().fg(C_DIM)
|
|
|
|
|
} else {
|
|
|
|
|
Style::default().fg(C_TEXT)
|
|
|
|
|
};
|
|
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(cmd)
|
|
|
|
|
.style(cmd_style)
|
|
|
|
|
.block(self.pane_block("composer", Pane::Composer)),
|
|
|
|
|
cols[3],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let footer = self.footer_line(area.width);
|
|
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(footer).style(Style::default().fg(C_DIM)),
|
|
|
|
|
cols[4],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn footer_line(&self, width: u16) -> String {
|
|
|
|
|
status_footer(
|
|
|
|
|
width,
|
|
|
|
|
&self.me_fp,
|
|
|
|
|
&self.me_onion,
|
|
|
|
|
self.status_note.as_deref(),
|
|
|
|
|
footer_hints(&self.screen),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn chat_lines(&self) -> Vec<String> {
|
|
|
|
|
let Some(friend) = self.friends.get(self.selected) else {
|
|
|
|
|
return vec!["no friends yet".into(), "F3 paste a QR to add one".into()];
|
|
|
|
|
};
|
|
|
|
|
match self.node.list_messages(&friend.pubkey) {
|
|
|
|
|
Ok(msgs) if msgs.is_empty() => vec!["no messages".into()],
|
|
|
|
|
Ok(msgs) => msgs
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|m| {
|
|
|
|
|
let side = if m.dir == "out" { "you" } else { "them" };
|
|
|
|
|
format!("{side}: {}", String::from_utf8_lossy(&m.plaintext))
|
|
|
|
|
})
|
|
|
|
|
.collect(),
|
|
|
|
|
Err(e) => vec![e],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn pane_block<'a>(&self, title: &'a str, pane: Pane) -> Block<'a> {
|
|
|
|
|
let focused = self.focus == pane;
|
|
|
|
|
let border = if focused { C_ACCENT } else { C_BORDER };
|
|
|
|
|
let title_style = if focused {
|
|
|
|
|
Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD)
|
|
|
|
|
} else {
|
|
|
|
|
Style::default().fg(C_DIM)
|
|
|
|
|
};
|
|
|
|
|
Block::default()
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.border_style(Style::default().fg(border))
|
|
|
|
|
.title(title)
|
|
|
|
|
.title_style(title_style)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Status bar: TOR + identity, then as many hints as fit, always ending with `? help`.
|
|
|
|
|
pub fn status_footer(
|
|
|
|
|
width: u16,
|
|
|
|
|
me_fp: &str,
|
|
|
|
|
me_onion: &str,
|
|
|
|
|
status_note: Option<&str>,
|
|
|
|
|
hints: &str,
|
|
|
|
|
) -> String {
|
|
|
|
|
let w = width as usize;
|
|
|
|
|
let onion = if me_onion.is_empty() {
|
|
|
|
|
"unset".to_string()
|
|
|
|
|
} else if width >= 110 {
|
|
|
|
|
me_onion.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
truncate_fp(me_onion)
|
|
|
|
|
};
|
|
|
|
|
let ident = format!("TOR me:{} onion:{onion}", truncate_fp(me_fp));
|
|
|
|
|
let note = status_note.unwrap_or("").trim();
|
|
|
|
|
let extra = hints.replace("? help", "");
|
|
|
|
|
let extra = extra.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
|
|
|
let help = "? help";
|
|
|
|
|
let candidates = [
|
|
|
|
|
join_footer(&[note, ident.as_str(), extra.as_str(), help]),
|
|
|
|
|
join_footer(&[ident.as_str(), extra.as_str(), help]),
|
|
|
|
|
join_footer(&[note, ident.as_str(), help]),
|
|
|
|
|
join_footer(&[ident.as_str(), help]),
|
|
|
|
|
];
|
|
|
|
|
for line in candidates {
|
|
|
|
|
if line.chars().count() <= w {
|
|
|
|
|
return line;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let suffix = format!(" {help}");
|
|
|
|
|
let budget = w.saturating_sub(suffix.chars().count());
|
|
|
|
|
let head: String = ident.chars().take(budget).collect();
|
|
|
|
|
format!("{head}{suffix}")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn join_footer(parts: &[&str]) -> String {
|
|
|
|
|
parts
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|p| p.trim())
|
|
|
|
|
.filter(|p| !p.is_empty())
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join(" ")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Main-screen key hints. Kept short so width 80 still shows TOR + identity + `? help`.
|
|
|
|
|
pub fn main_footer_hints() -> &'static str {
|
|
|
|
|
"Tab F2 F3 F4 ? help Ctrl-Q"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn footer_hints(screen: &Screen) -> &'static str {
|
|
|
|
|
match screen {
|
|
|
|
|
Screen::Main => main_footer_hints(),
|
|
|
|
|
Screen::Share { .. } => "Esc back ? help",
|
|
|
|
|
Screen::Paste { .. } => "Enter submit Esc back ? help",
|
|
|
|
|
Screen::Approve { .. } => "y accept n reject ? help",
|
|
|
|
|
Screen::Rotate { .. } => "type ROTATE Esc cancel ? help",
|
|
|
|
|
Screen::Wipe { kind, .. } => match kind {
|
|
|
|
|
WipeKind::Messages => "type WIPE Esc cancel ? help",
|
|
|
|
|
WipeKind::All => "type WIPEALL Esc cancel ? help",
|
|
|
|
|
},
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
fn themed_block(title: &str) -> Block<'_> {
|
|
|
|
|
Block::default()
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.border_style(Style::default().fg(C_ACCENT))
|
|
|
|
|
.title(title)
|
|
|
|
|
.title_style(Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD))
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:54:19 -04:00
|
|
|
fn draw_share(f: &mut Frame, art: &str, payload: &str) {
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
let glyph = onion_glyph();
|
|
|
|
|
let body = format!("{glyph}\n\n{art}\n{payload}\n\nEsc back");
|
2026-09-10 01:54:19 -04:00
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(body)
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
.style(Style::default().fg(C_TEXT))
|
2026-09-10 01:54:19 -04:00
|
|
|
.wrap(Wrap { trim: false })
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
.block(themed_block("(o) share")),
|
2026-09-10 01:54:19 -04:00
|
|
|
f.area(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn draw_paste(f: &mut Frame, buf: &str, err: Option<&str>) {
|
|
|
|
|
let hint = err.unwrap_or("paste onionwire:v1 payload, Enter to submit");
|
|
|
|
|
let body = format!("{hint}\n\n{buf}");
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
let style = if err.is_some() {
|
|
|
|
|
Style::default().fg(C_ALERT)
|
|
|
|
|
} else {
|
|
|
|
|
Style::default().fg(C_TEXT)
|
|
|
|
|
};
|
2026-09-10 01:54:19 -04:00
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(body)
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
.style(style)
|
2026-09-10 01:54:19 -04:00
|
|
|
.wrap(Wrap { trim: false })
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
.block(themed_block("(o) paste QR")),
|
2026-09-10 01:54:19 -04:00
|
|
|
f.area(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn draw_approve(f: &mut Frame, payload: &QrPayload) {
|
|
|
|
|
let fp = to_hex(&payload.pubkey);
|
|
|
|
|
let body = format!(
|
|
|
|
|
"Unknown key. Add this friend?\n\nfp {}\nonion {}\n\ny accept n / Esc reject",
|
|
|
|
|
truncate_fp(&fp),
|
|
|
|
|
payload.onion
|
|
|
|
|
);
|
|
|
|
|
f.render_widget(
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
Paragraph::new(body)
|
|
|
|
|
.style(Style::default().fg(C_TEXT))
|
|
|
|
|
.block(themed_block("(o) approve")),
|
2026-09-10 01:54:19 -04:00
|
|
|
f.area(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:43:45 -04:00
|
|
|
fn draw_rotate(f: &mut Frame, typed: &str) {
|
|
|
|
|
let body = format!("{}\n\n{typed}", rotate_screen_text());
|
2026-09-10 01:54:19 -04:00
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(body)
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
.style(Style::default().fg(C_TEXT))
|
2026-09-10 01:54:19 -04:00
|
|
|
.wrap(Wrap { trim: false })
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
.block(themed_block("(o) rotate locator")),
|
2026-09-10 01:54:19 -04:00
|
|
|
f.area(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:49:42 -04:00
|
|
|
fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) {
|
|
|
|
|
let title = match kind {
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
WipeKind::Messages => "(o) wipe",
|
|
|
|
|
WipeKind::All => "(o) wipe all",
|
2026-09-10 02:49:42 -04:00
|
|
|
};
|
|
|
|
|
let body = format!("{}\n\n{typed}", wipe_screen_text(kind));
|
|
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(body)
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
.style(Style::default().fg(C_TEXT))
|
2026-09-10 02:49:42 -04:00
|
|
|
.wrap(Wrap { trim: false })
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
.block(themed_block(title)),
|
2026-09-10 02:49:42 -04:00
|
|
|
f.area(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
tui: ASCII onion chrome, themed panes, and real keyboard navigation
- Hand-written wordmark banner (<=80 cols, auto-collapses to a compact
mark below banner width) plus onion glyph and themed screen headings
- Color-palette consts, stateful pane titles, highlighted selection row
(accent + bg), context-sensitive footer that always keeps '? help'
- Pane focus model (roster/chat/composer) with Tab/Shift-Tab, 1/2/3,
j/k + arrows, g/G, and a '?' help overlay; Esc closes any overlay
- New tests/tui_chrome.rs: banner widths, help overlay bindings, real
footer helper at 80 and 120 cols, 80x24 draw smoke test
- README keybinding table updated to match
Locked helper strings (fingerprint_mismatch_banner, rotate_screen_text,
wipe_screen_text, parse_slash) unchanged; UI-only (src/tui.rs).
2026-09-10 13:01:40 -04:00
|
|
|
fn draw_help_overlay(f: &mut Frame) {
|
|
|
|
|
let area = f.area();
|
|
|
|
|
let text = help_overlay_text();
|
|
|
|
|
let lines = text.lines().count() as u16 + 2;
|
|
|
|
|
let text_w = text
|
|
|
|
|
.lines()
|
|
|
|
|
.map(|l| l.chars().count() as u16)
|
|
|
|
|
.max()
|
|
|
|
|
.unwrap_or(40)
|
|
|
|
|
.saturating_add(4);
|
|
|
|
|
let width = text_w.min(area.width.saturating_sub(2)).max(10);
|
|
|
|
|
let height = lines.min(area.height.saturating_sub(2)).max(3);
|
|
|
|
|
let popup = Rect {
|
|
|
|
|
x: area.x + (area.width.saturating_sub(width)) / 2,
|
|
|
|
|
y: area.y + (area.height.saturating_sub(height)) / 2,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
};
|
|
|
|
|
f.render_widget(Clear, popup);
|
|
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(text)
|
|
|
|
|
.style(Style::default().fg(C_TEXT))
|
|
|
|
|
.block(themed_block("? help")),
|
|
|
|
|
popup,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:54:19 -04:00
|
|
|
fn truncate_fp(fp: &str) -> String {
|
|
|
|
|
let s: String = fp.chars().take(8).collect();
|
|
|
|
|
format!("{s}…")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn to_hex(bytes: &[u8]) -> String {
|
|
|
|
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn io_err(e: io::Error) -> String {
|
|
|
|
|
e.to_string()
|
|
|
|
|
}
|