onionwire/src/tui.rs

1165 lines
35 KiB
Rust
Raw Normal View History

use std::io;
use std::sync::Arc;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{DefaultTerminal, Frame};
use crate::node::Node;
use crate::qr::{self, QrPayload};
use crate::store::{Friend, FriendProfile};
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 {
"fingerprint mismatch"
}
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"
}
/// 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 /profile /who\n\
Esc close overlay, go back, clear composer\n\
F2 share QR\n\
F3 paste a friend QR\n\
F4 rotate onion locator\n\
F5 selected friend's profile (/who)\n\
/profile edit name, bio, Monero address\n\
? this help\n\
Ctrl-Q quit\n\
\n\
Esc closes this overlay"
}
pub fn who_overlay_text(fingerprint: &str, onion: &str, profile: Option<&FriendProfile>) -> String {
match profile {
None => format!(
"friend profile\n\nfp {fingerprint}\nonion {onion}\n\nno profile yet\n\nEsc closes"
),
Some(p) => {
let name = if p.display_name.is_empty() {
"(none)"
} else {
p.display_name.as_str()
};
let bio = if p.bio.is_empty() {
"(none)"
} else {
p.bio.as_str()
};
let xmr = if p.xmr_addr.is_empty() {
"(none)"
} else {
p.xmr_addr.as_str()
};
format!(
"friend profile\n\nname {name}\nbio {bio}\nxmr {xmr}\nfp {fingerprint}\nonion {onion}\n\nEsc closes"
)
}
}
}
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)]
pub enum WipeKind {
Messages,
All,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SlashCmd {
Wipe(WipeKind),
Profile,
Who,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileDraft {
pub display_name: String,
pub bio: String,
pub xmr_addr: String,
}
#[derive(Debug, Clone)]
pub struct ProfileEditor {
name: String,
bio: String,
xmr: String,
field: usize,
}
impl ProfileEditor {
pub fn new(display_name: &str, bio: &str, xmr_addr: &str) -> Self {
Self {
name: display_name.to_string(),
bio: bio.to_string(),
xmr: xmr_addr.to_string(),
field: 0,
}
}
pub fn on_enter(&self) -> ProfileDraft {
ProfileDraft {
display_name: self.name.clone(),
bio: self.bio.clone(),
xmr_addr: self.xmr.clone(),
}
}
pub fn on_tab(&mut self) {
self.field = (self.field + 1) % 3;
}
pub fn on_backtab(&mut self) {
self.field = (self.field + 2) % 3;
}
pub fn on_char(&mut self, c: char) {
if c == '\n' {
return;
}
self.field_mut().push(c);
}
pub fn on_backspace(&mut self) {
self.field_mut().pop();
}
pub fn field(&self) -> usize {
self.field
}
pub fn display_name(&self) -> &str {
&self.name
}
pub fn bio(&self) -> &str {
&self.bio
}
pub fn xmr_addr(&self) -> &str {
&self.xmr
}
fn field_mut(&mut self) -> &mut String {
match self.field {
0 => &mut self.name,
1 => &mut self.bio,
_ => &mut self.xmr,
}
}
}
#[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_cmd(raw: &str) -> Option<SlashCmd> {
match raw.trim() {
"/wipe" => Some(SlashCmd::Wipe(WipeKind::Messages)),
"/wipe-all" => Some(SlashCmd::Wipe(WipeKind::All)),
"/profile" => Some(SlashCmd::Profile),
"/who" => Some(SlashCmd::Who),
_ => None,
}
}
pub fn parse_slash(raw: &str) -> Option<WipeKind> {
match parse_cmd(raw) {
Some(SlashCmd::Wipe(k)) => Some(k),
_ => 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
}
}
#[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
}
}
pub fn run(node: Arc<Node>, rt: tokio::runtime::Handle) -> Result<AppExit, String> {
let mut app = App::new(node, rt)?;
let mut terminal = ratatui::init();
let result = app.run(&mut terminal);
ratatui::restore();
result
}
struct App {
node: Arc<Node>,
rt: tokio::runtime::Handle,
screen: Screen,
friends: Vec<Friend>,
selected: usize,
me_fp: String,
me_onion: String,
alert: Option<String>,
status_note: Option<String>,
composer: String,
focus: Pane,
help_open: bool,
who_open: bool,
chat_scroll: u16,
}
enum Screen {
Main,
Share { art: String, payload: String },
Paste { buf: String, err: Option<String> },
Approve { payload: QrPayload },
Rotate { prompt: RotatePrompt },
Wipe { kind: WipeKind, prompt: WipePrompt },
Profile { editor: ProfileEditor },
}
impl App {
fn new(node: Arc<Node>, rt: tokio::runtime::Handle) -> Result<Self, String> {
let friends = node.list_friends()?;
Ok(Self {
me_fp: to_hex(&node.identity_pk()),
me_onion: node.onion(),
node,
rt,
screen: Screen::Main,
friends,
selected: 0,
alert: None,
status_note: None,
composer: String::new(),
focus: Pane::Composer,
help_open: false,
who_open: false,
chat_scroll: 0,
})
}
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<AppExit, String> {
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) {
return Ok(AppExit::Quit);
}
if let Some(exit) = self.handle_key(key)? {
return Ok(exit);
}
}
}
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 self.who_open {
match key.code {
KeyCode::Esc | KeyCode::F(5) => self.who_open = false,
KeyCode::Char('?') => {
self.who_open = false;
self.help_open = true;
}
_ => {}
}
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 profile_save = None;
let mut open_profile = false;
match &mut self.screen {
Screen::Main => match key.code {
KeyCode::Tab => self.focus = self.focus.next(),
KeyCode::BackTab => self.focus = self.focus.prev(),
KeyCode::Up => self.nav_up(),
KeyCode::Down => self.nav_down(),
KeyCode::F(2) => self.open_share()?,
KeyCode::F(3) => {
self.screen = Screen::Paste {
buf: String::new(),
err: None,
};
}
KeyCode::F(4) => {
self.screen = Screen::Rotate {
prompt: RotatePrompt::new(),
};
}
KeyCode::F(5) => self.who_open = true,
KeyCode::Esc => self.composer.clear(),
KeyCode::Backspace => {
if self.focus == Pane::Composer {
self.composer.pop();
}
}
KeyCode::Enter => match parse_cmd(&self.composer) {
Some(SlashCmd::Wipe(kind)) => {
self.composer.clear();
let prompt = match kind {
WipeKind::Messages => WipePrompt::messages(),
WipeKind::All => WipePrompt::all(),
};
self.screen = Screen::Wipe { kind, prompt };
}
Some(SlashCmd::Profile) => {
self.composer.clear();
open_profile = true;
}
Some(SlashCmd::Who) => {
self.composer.clear();
self.who_open = true;
}
None => {}
},
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
self.handle_main_char(c);
}
_ => {}
},
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,
_ => {}
},
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()?;
}
}
KeyCode::Backspace => {
prompt.on_char('\u{8}');
}
_ => {}
},
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}');
}
_ => {}
}
}
Screen::Profile { editor } => match key.code {
KeyCode::Esc => self.screen = Screen::Main,
KeyCode::Tab => editor.on_tab(),
KeyCode::BackTab => editor.on_backtab(),
KeyCode::Backspace => editor.on_backspace(),
KeyCode::Enter => profile_save = Some(editor.on_enter()),
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
editor.on_char(c);
}
_ => {}
},
}
if let Some(kind) = wipe_confirm {
return self.confirm_wipe(kind);
}
if open_profile {
self.open_profile()?;
}
if let Some(draft) = profile_save {
self.save_profile(draft)?;
}
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> {
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)),
}
}
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(())
}
}
}
fn open_share(&mut self) -> Result<(), String> {
let payload = self.node.qr_payload()?;
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) => {
let known = self.node.get_friend(&payload.pubkey)?.is_some();
if known {
self.accept_friend(&payload)?;
} else {
self.screen = Screen::Approve { payload };
}
}
}
Ok(())
}
fn accept_friend(&mut self, payload: &QrPayload) -> Result<(), String> {
self.node.add_friend_payload(payload)?;
self.reload_friends()?;
self.screen = Screen::Main;
Ok(())
}
fn reload_friends(&mut self) -> Result<(), String> {
self.friends = self.node.list_friends()?;
if self.friends.is_empty() {
self.selected = 0;
} else {
self.selected = self.selected.min(self.friends.len() - 1);
}
self.chat_scroll = u16::MAX;
Ok(())
}
fn open_profile(&mut self) -> Result<(), String> {
let p = self.node.self_profile()?;
self.screen = Screen::Profile {
editor: ProfileEditor::new(&p.display_name, &p.bio, &p.xmr_addr),
};
Ok(())
}
fn save_profile(&mut self, draft: ProfileDraft) -> Result<(), String> {
if let Err(e) = self
.node
.set_self_profile(&draft.display_name, &draft.bio, &draft.xmr_addr)
{
self.alert = Some(e);
return Ok(());
}
self.screen = Screen::Main;
if let Some(friend) = self.friends.get(self.selected) {
let pk = friend.pubkey.clone();
match self.rt.block_on(self.node.push_self_profile(&pk)) {
Ok(()) => self.status_note = Some("profile saved · sent".into()),
Err(e) => self.status_note = Some(format!("profile send failed: {e}")),
}
} else {
self.status_note = Some("profile saved".into());
}
Ok(())
}
fn who_body(&self) -> String {
let Some(friend) = self.friends.get(self.selected) else {
return "no friend selected\n\nEsc closes".into();
};
let profile = self.node.friend_profile(&friend.pubkey).ok().flatten();
who_overlay_text(&friend.fingerprint, &friend.onion, profile.as_ref())
}
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),
Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()),
Screen::Wipe { kind, prompt } => draw_wipe(f, *kind, prompt.typed()),
Screen::Profile { editor } => draw_profile(f, editor),
}
if self.help_open {
draw_text_overlay(f, "? help", help_overlay_text());
} else if self.who_open {
draw_text_overlay(f, "/who", &self.who_body());
}
}
fn draw_main(&self, f: &mut Frame) {
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([
Constraint::Length(banner_h),
Constraint::Length(alert_h),
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.split(area);
f.render_widget(
Paragraph::new(banner).style(Style::default().fg(C_ACCENT)),
cols[0],
);
if let Some(msg) = &self.alert {
f.render_widget(
Paragraph::new(msg.as_str()).style(Style::default().fg(C_ALERT)),
cols[1],
);
}
let panes =
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
.friends
.iter()
.map(|friend| {
let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint);
let fp = truncate_fp(&friend.fingerprint);
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)
})
.collect();
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
};
let chat_text = chat_lines.join("\n");
let chat = Paragraph::new(chat_text)
.style(Style::default().fg(C_TEXT))
.wrap(Wrap { trim: false })
.scroll((scroll, 0))
.block(self.pane_block(&chat_title, Pane::Chat));
f.render_widget(chat, panes[1]);
let cmd = if self.composer.is_empty() {
"/wipe /wipe-all /profile /who".to_string()
} else {
self.composer.clone()
};
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 F5 ? 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",
},
Screen::Profile { .. } => "Enter save Tab field 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) {
let glyph = onion_glyph();
let body = format!("{glyph}\n\n{art}\n{payload}\n\nEsc back");
f.render_widget(
Paragraph::new(body)
.style(Style::default().fg(C_TEXT))
.wrap(Wrap { trim: false })
.block(themed_block("(o) share")),
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}");
let style = if err.is_some() {
Style::default().fg(C_ALERT)
} else {
Style::default().fg(C_TEXT)
};
f.render_widget(
Paragraph::new(body)
.style(style)
.wrap(Wrap { trim: false })
.block(themed_block("(o) paste QR")),
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(
Paragraph::new(body)
.style(Style::default().fg(C_TEXT))
.block(themed_block("(o) approve")),
f.area(),
);
}
fn draw_rotate(f: &mut Frame, typed: &str) {
let body = format!("{}\n\n{typed}", rotate_screen_text());
f.render_widget(
Paragraph::new(body)
.style(Style::default().fg(C_TEXT))
.wrap(Wrap { trim: false })
.block(themed_block("(o) rotate locator")),
f.area(),
);
}
fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) {
let title = match kind {
WipeKind::Messages => "(o) wipe",
WipeKind::All => "(o) wipe all",
};
let body = format!("{}\n\n{typed}", wipe_screen_text(kind));
f.render_widget(
Paragraph::new(body)
.style(Style::default().fg(C_TEXT))
.wrap(Wrap { trim: false })
.block(themed_block(title)),
f.area(),
);
}
fn draw_profile(f: &mut Frame, editor: &ProfileEditor) {
let labels = ["name", "bio", "xmr"];
let values = [editor.display_name(), editor.bio(), editor.xmr_addr()];
let mut body = String::from(
"Your profile (friend-visible)\nEnter saves Esc cancels Tab next field\n",
);
for (i, (label, value)) in labels.iter().zip(values).enumerate() {
let mark = if editor.field() == i { ">" } else { " " };
body.push_str(&format!("\n{mark} {label}: {value}"));
}
f.render_widget(
Paragraph::new(body)
.style(Style::default().fg(C_TEXT))
.wrap(Wrap { trim: false })
.block(themed_block("(o) profile")),
f.area(),
);
}
fn draw_text_overlay(f: &mut Frame, title: &str, text: &str) {
let area = f.area();
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(title)),
popup,
);
}
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()
}