tui: ASCII onion chrome, themed panes, and real keyboard navigation
Some checks are pending
ci / test (push) Waiting to run

- 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).
This commit is contained in:
Sirius DevOps 2026-09-10 13:01:40 -04:00
parent 5c5948c61c
commit 82634db142
No known key found for this signature in database
3 changed files with 604 additions and 64 deletions

View file

@ -113,15 +113,21 @@ A friend is an ed25519 pubkey (`UNIQUE(pubkey)`). The `.onion` on that row is on
## Keys (TUI) ## Keys (TUI)
Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j`/`k` and arrows only move the focused pane (roster or chat). `1`/`2`/`3` switch focus when you are not typing in the composer. `?` opens help from every screen except paste (so a payload can contain `?`).
| Key | Action | | Key | Action |
|---|---| |---|---|
| `Tab` / `Shift-Tab` | Cycle focus: roster → chat → composer |
| `1` / `2` / `3` | Focus roster / chat / composer (when not typing) |
| `j` `k` / `↑` `↓` | Move or scroll the focused pane |
| `g` / `G` | Jump to top / bottom of the focused pane |
| `?` | Keybinding help (`Esc` closes) |
| `F2` | Share: terminal QR + payload | | `F2` | Share: terminal QR + payload |
| `F3` | Paste a friends payload | | `F3` | Paste a friends payload |
| `F4` | Rotate **onion** (locator only) | | `F4` | Rotate **onion** (locator only) |
| `↑` / `↓` | Roster | | Enter | Run `/wipe` or `/wipe-all` from the composer |
| Enter | Send (composer) | | `Esc` | Close overlay / back to Main / clear composer |
| `Ctrl-Q` | Quit | | `Ctrl-Q` | Quit |
| Esc | Cancel overlay / clear composer |
## F2 QR ## F2 QR

View file

@ -2,15 +2,23 @@ use std::io;
use std::sync::Arc; use std::sync::Arc;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Layout}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{DefaultTerminal, Frame}; use ratatui::{DefaultTerminal, Frame};
use crate::node::Node; use crate::node::Node;
use crate::qr::{self, QrPayload}; use crate::qr::{self, QrPayload};
use crate::store::Friend; use crate::store::Friend;
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);
pub fn fingerprint_mismatch_banner() -> &'static str { pub fn fingerprint_mismatch_banner() -> &'static str {
"fingerprint mismatch" "fingerprint mismatch"
} }
@ -23,6 +31,80 @@ Offline friends CANNOT find you until they rescan your QR.\n\
Type ROTATE to confirm Esc to cancel" Type ROTATE to confirm Esc to cancel"
} }
/// 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,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WipeKind { pub enum WipeKind {
Messages, Messages,
@ -178,6 +260,9 @@ struct App {
alert: Option<String>, alert: Option<String>,
status_note: Option<String>, status_note: Option<String>,
composer: String, composer: String,
focus: Pane,
help_open: bool,
chat_scroll: u16,
} }
enum Screen { enum Screen {
@ -203,6 +288,9 @@ impl App {
alert: None, alert: None,
status_note: None, status_note: None,
composer: String::new(), composer: String::new(),
focus: Pane::Composer,
help_open: false,
chat_scroll: 0,
}) })
} }
@ -225,17 +313,25 @@ impl App {
} }
fn handle_key(&mut self, key: KeyEvent) -> Result<Option<AppExit>, String> { fn handle_key(&mut self, key: KeyEvent) -> Result<Option<AppExit>, String> {
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);
}
let mut wipe_confirm = None; let mut wipe_confirm = None;
match &mut self.screen { match &mut self.screen {
Screen::Main => match key.code { Screen::Main => match key.code {
KeyCode::Up => { KeyCode::Tab => self.focus = self.focus.next(),
self.selected = self.selected.saturating_sub(1); KeyCode::BackTab => self.focus = self.focus.prev(),
} KeyCode::Up => self.nav_up(),
KeyCode::Down => { KeyCode::Down => self.nav_down(),
if !self.friends.is_empty() {
self.selected = (self.selected + 1).min(self.friends.len() - 1);
}
}
KeyCode::F(2) => self.open_share()?, KeyCode::F(2) => self.open_share()?,
KeyCode::F(3) => { KeyCode::F(3) => {
self.screen = Screen::Paste { self.screen = Screen::Paste {
@ -250,8 +346,10 @@ impl App {
} }
KeyCode::Esc => self.composer.clear(), KeyCode::Esc => self.composer.clear(),
KeyCode::Backspace => { KeyCode::Backspace => {
if self.focus == Pane::Composer {
self.composer.pop(); self.composer.pop();
} }
}
KeyCode::Enter => { KeyCode::Enter => {
if let Some(kind) = parse_slash(&self.composer) { if let Some(kind) = parse_slash(&self.composer) {
self.composer.clear(); self.composer.clear();
@ -263,7 +361,7 @@ impl App {
} }
} }
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
self.composer.push(c); self.handle_main_char(c);
} }
_ => {} _ => {}
}, },
@ -330,6 +428,75 @@ impl App {
Ok(None) Ok(None)
} }
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 => {}
}
}
fn confirm_wipe(&mut self, kind: WipeKind) -> Result<Option<AppExit>, String> { fn confirm_wipe(&mut self, kind: WipeKind) -> Result<Option<AppExit>, String> {
match kind { match kind {
WipeKind::Messages => { WipeKind::Messages => {
@ -402,6 +569,7 @@ impl App {
} else { } else {
self.selected = self.selected.min(self.friends.len() - 1); self.selected = self.selected.min(self.friends.len() - 1);
} }
self.chat_scroll = u16::MAX;
Ok(()) Ok(())
} }
@ -414,87 +582,260 @@ impl App {
Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()), Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()),
Screen::Wipe { kind, prompt } => draw_wipe(f, *kind, prompt.typed()), Screen::Wipe { kind, prompt } => draw_wipe(f, *kind, prompt.typed()),
} }
if self.help_open {
draw_help_overlay(f);
}
} }
fn draw_main(&self, f: &mut Frame) { fn draw_main(&self, f: &mut Frame) {
let area = f.area(); let area = f.area();
let banner = banner_for_width(area.width);
let banner_h = banner.lines().count() as u16;
let alert_h = u16::from(self.alert.is_some());
let cols = Layout::vertical([ let cols = Layout::vertical([
Constraint::Min(1), Constraint::Length(banner_h),
Constraint::Length(1), Constraint::Length(alert_h),
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1), Constraint::Length(1),
]) ])
.split(area); .split(area);
let body = if self.alert.is_some() {
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(cols[0]) f.render_widget(
} else { Paragraph::new(banner).style(Style::default().fg(C_ACCENT)),
Layout::vertical([Constraint::Length(0), Constraint::Min(1)]).split(cols[0]) cols[0],
}; );
if let Some(msg) = &self.alert { if let Some(msg) = &self.alert {
f.render_widget( f.render_widget(
Paragraph::new(msg.as_str()).style(Style::default().fg(Color::Red)), Paragraph::new(msg.as_str()).style(Style::default().fg(C_ALERT)),
body[0], cols[1],
); );
} }
let panes = let panes =
Layout::horizontal([Constraint::Percentage(36), Constraint::Min(10)]).split(body[1]); 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())
};
let items: Vec<ListItem> = self let items: Vec<ListItem> = self
.friends .friends
.iter() .iter()
.enumerate() .map(|friend| {
.map(|(i, friend)| {
let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint); let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint);
let fp = truncate_fp(&friend.fingerprint); let fp = truncate_fp(&friend.fingerprint);
let stale = if friend.last_connect_ok { "" } else { " stale" }; let mut line = format!("{name} {fp}");
let mark = if i == self.selected { ">" } else { " " }; if !friend.last_connect_ok {
ListItem::new(format!("{mark} {name} {fp}{stale}")) 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)
}) })
.collect(); .collect();
let roster = List::new(items).block(Block::default().borders(Borders::ALL).title("roster")); let roster = List::new(items)
f.render_widget(roster, panes[0]); .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_text = if let Some(friend) = self.friends.get(self.selected) { let chat_lines = self.chat_lines();
let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint); let inner_h = panes[1].height.saturating_sub(2);
format!("{name}\n\nchat arrives in M3") let nlines = chat_lines.len();
} else { let max_scroll = nlines.saturating_sub(inner_h as usize) as u16;
"no friends yet\nF3 paste a QR to add one".into() 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
};
let chat_text = chat_lines.join("\n");
let chat = Paragraph::new(chat_text) let chat = Paragraph::new(chat_text)
.style(Style::default().fg(C_TEXT))
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("chat")); .scroll((scroll, 0))
.block(self.pane_block(&chat_title, Pane::Chat));
f.render_widget(chat, panes[1]); f.render_widget(chat, panes[1]);
let onion = if self.me_onion.is_empty() {
"unset"
} else {
self.me_onion.as_str()
};
let note = self
.status_note
.as_deref()
.map(|s| format!("{s} | "))
.unwrap_or_default();
let status = format!(
"{note}TOR: up | me: {} | onion: {} F2 share F3 paste F4 rotate Ctrl-Q quit",
truncate_fp(&self.me_fp),
onion
);
let cmd = if self.composer.is_empty() { let cmd = if self.composer.is_empty() {
"/wipe /wipe-all".to_string() "/wipe /wipe-all".to_string()
} else { } else {
self.composer.clone() self.composer.clone()
}; };
f.render_widget(Paragraph::new(cmd), cols[1]); let cmd_style = if self.composer.is_empty() {
f.render_widget(Paragraph::new(status), cols[2]); 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",
},
}
}
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))
}
fn draw_share(f: &mut Frame, art: &str, payload: &str) { fn draw_share(f: &mut Frame, art: &str, payload: &str) {
let body = format!("{art}\n{payload}\n\nEsc back"); let glyph = onion_glyph();
let body = format!("{glyph}\n\n{art}\n{payload}\n\nEsc back");
f.render_widget( f.render_widget(
Paragraph::new(body) Paragraph::new(body)
.style(Style::default().fg(C_TEXT))
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("F2 share")), .block(themed_block("(o) share")),
f.area(), f.area(),
); );
} }
@ -502,10 +843,16 @@ fn draw_share(f: &mut Frame, art: &str, payload: &str) {
fn draw_paste(f: &mut Frame, buf: &str, err: Option<&str>) { fn draw_paste(f: &mut Frame, buf: &str, err: Option<&str>) {
let hint = err.unwrap_or("paste onionwire:v1 payload, Enter to submit"); let hint = err.unwrap_or("paste onionwire:v1 payload, Enter to submit");
let body = format!("{hint}\n\n{buf}"); let body = format!("{hint}\n\n{buf}");
let style = if err.is_some() {
Style::default().fg(C_ALERT)
} else {
Style::default().fg(C_TEXT)
};
f.render_widget( f.render_widget(
Paragraph::new(body) Paragraph::new(body)
.style(style)
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("F3 paste QR")), .block(themed_block("(o) paste QR")),
f.area(), f.area(),
); );
} }
@ -518,7 +865,9 @@ fn draw_approve(f: &mut Frame, payload: &QrPayload) {
payload.onion payload.onion
); );
f.render_widget( f.render_widget(
Paragraph::new(body).block(Block::default().borders(Borders::ALL).title("approve")), Paragraph::new(body)
.style(Style::default().fg(C_TEXT))
.block(themed_block("(o) approve")),
f.area(), f.area(),
); );
} }
@ -527,26 +876,55 @@ fn draw_rotate(f: &mut Frame, typed: &str) {
let body = format!("{}\n\n{typed}", rotate_screen_text()); let body = format!("{}\n\n{typed}", rotate_screen_text());
f.render_widget( f.render_widget(
Paragraph::new(body) Paragraph::new(body)
.style(Style::default().fg(C_TEXT))
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("F4 rotate")), .block(themed_block("(o) rotate locator")),
f.area(), f.area(),
); );
} }
fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) { fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) {
let title = match kind { let title = match kind {
WipeKind::Messages => "/wipe", WipeKind::Messages => "(o) wipe",
WipeKind::All => "/wipe-all", WipeKind::All => "(o) wipe all",
}; };
let body = format!("{}\n\n{typed}", wipe_screen_text(kind)); let body = format!("{}\n\n{typed}", wipe_screen_text(kind));
f.render_widget( f.render_widget(
Paragraph::new(body) Paragraph::new(body)
.style(Style::default().fg(C_TEXT))
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title(title)), .block(themed_block(title)),
f.area(), f.area(),
); );
} }
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,
);
}
fn truncate_fp(fp: &str) -> String { fn truncate_fp(fp: &str) -> String {
let s: String = fp.chars().take(8).collect(); let s: String = fp.chars().take(8).collect();
format!("{s}") format!("{s}")

156
tests/tui_chrome.rs Normal file
View file

@ -0,0 +1,156 @@
//! Main-screen chrome helpers: ASCII wordmark, compact fallback, help text.
use onionwire::tui::{
banner_for_width, compact_banner, help_overlay_text, main_footer_hints, onion_glyph,
status_footer, wordmark_banner, Pane,
};
#[test]
fn wordmark_banner_is_nonempty_and_fits_80() {
let banner = wordmark_banner();
assert!(!banner.is_empty());
for line in banner.lines() {
assert!(
line.chars().count() <= 80,
"wordmark line exceeds 80 chars: {line:?} ({})",
line.chars().count()
);
}
}
#[test]
fn compact_banner_is_nonempty_and_fits_40() {
let compact = compact_banner();
assert!(!compact.is_empty());
assert!(
compact.chars().count() <= 40,
"compact banner exceeds 40 chars ({})",
compact.chars().count()
);
}
#[test]
fn onion_glyph_is_nonempty() {
assert!(!onion_glyph().is_empty());
}
#[test]
fn help_overlay_lists_core_bindings() {
let help = help_overlay_text();
assert!(!help.is_empty());
for needle in ["Tab", "F2", "F3", "F4", "Ctrl-Q", "?"] {
assert!(help.contains(needle), "help overlay missing {needle:?}");
}
for line in help.lines() {
assert!(
line.chars().count() <= 80,
"help line exceeds 80 chars: {line:?}"
);
}
}
#[test]
fn banner_for_width_collapses_when_narrow() {
let full = wordmark_banner();
let max_line = full.lines().map(|l| l.chars().count()).max().unwrap();
assert_eq!(banner_for_width(max_line as u16), full);
assert_eq!(banner_for_width(max_line as u16 - 1), compact_banner());
assert_eq!(banner_for_width(80), full);
assert_eq!(banner_for_width(120), full);
}
#[test]
fn chrome_renders_at_80x24_and_120x40() {
use ratatui::backend::TestBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::Paragraph;
use ratatui::Terminal;
let fp = "abcdef0123456789";
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
for (w, h) in [(80u16, 24u16), (120, 40)] {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|f| {
let banner = banner_for_width(f.area().width);
let banner_h = banner.lines().count() as u16;
let chunks = Layout::vertical([
Constraint::Length(banner_h),
Constraint::Min(3),
Constraint::Length(1),
])
.split(f.area());
f.render_widget(Paragraph::new(banner), chunks[0]);
let footer = status_footer(f.area().width, fp, onion, None, main_footer_hints());
f.render_widget(Paragraph::new(footer), chunks[2]);
})
.expect("draw");
let buf = terminal.backend().buffer();
let row0: String = (0..w).map(|x| buf[(x, 0)].symbol().to_string()).collect();
assert!(
row0.contains("ONIONWIRE"),
"banner missing on row 0 at {w}x{h}: {row0:?}"
);
let footer: String = (0..w)
.map(|x| buf[(x, h - 1)].symbol().to_string())
.collect();
assert!(
footer.contains("? help"),
"footer missing ? help at {w}x{h}: {footer:?}"
);
assert!(
footer.contains("TOR"),
"footer missing TOR at {w}x{h}: {footer:?}"
);
}
}
#[test]
fn main_footer_keeps_help_at_80_and_120() {
let fp = "abcdef0123456789deadbeef";
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
let hints = main_footer_hints();
assert!(hints.contains("? help"));
for w in [80u16, 120] {
let line = status_footer(w, fp, onion, None, hints);
assert!(
line.chars().count() <= w as usize,
"footer wider than {w}: {} {line:?}",
line.chars().count()
);
assert!(
line.contains("? help"),
"footer missing ? help at {w}: {line:?}"
);
assert!(line.contains("TOR"), "footer missing TOR at {w}: {line:?}");
assert!(
line.contains("me:"),
"footer missing identity at {w}: {line:?}"
);
}
let crowded = status_footer(80, fp, onion, Some("rotated · notified 1/2 friends"), hints);
assert!(
crowded.chars().count() <= 80,
"crowded footer wider than 80: {} {crowded:?}",
crowded.chars().count()
);
assert!(
crowded.contains("? help"),
"status note crowded out ? help: {crowded:?}"
);
assert!(
crowded.contains("TOR"),
"crowded footer missing TOR: {crowded:?}"
);
}
#[test]
fn pane_focus_cycles_roster_chat_composer() {
assert_eq!(Pane::Roster.next(), Pane::Chat);
assert_eq!(Pane::Chat.next(), Pane::Composer);
assert_eq!(Pane::Composer.next(), Pane::Roster);
assert_eq!(Pane::Roster.prev(), Pane::Composer);
assert_eq!(Pane::Chat.prev(), Pane::Roster);
assert_eq!(Pane::Composer.prev(), Pane::Chat);
}