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 /pay /tip\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\ /pay [memo] invoice to receive\n\ /tip [memo] pay selected friend\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, PartialEq, Eq)] pub enum SlashCmd { Wipe(WipeKind), Profile, Who, Pay { atomic: String, memo: String }, Tip { atomic: String, memo: String }, } #[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 { let s = raw.trim(); match s { "/wipe" => return Some(SlashCmd::Wipe(WipeKind::Messages)), "/wipe-all" => return Some(SlashCmd::Wipe(WipeKind::All)), "/profile" => return Some(SlashCmd::Profile), "/who" => return Some(SlashCmd::Who), _ => {} } if let Some(rest) = s.strip_prefix("/pay") && (rest.is_empty() || rest.starts_with(char::is_whitespace)) { return parse_amount_cmd(rest).map(|(atomic, memo)| SlashCmd::Pay { atomic, memo }); } if let Some(rest) = s.strip_prefix("/tip") && (rest.is_empty() || rest.starts_with(char::is_whitespace)) { return parse_amount_cmd(rest).map(|(atomic, memo)| SlashCmd::Tip { atomic, memo }); } None } fn parse_amount_cmd(rest: &str) -> Option<(String, String)> { let rest = rest.trim(); if rest.is_empty() { return None; } let mut parts = rest.splitn(2, char::is_whitespace); let amount = parts.next()?.trim(); let memo = parts.next().unwrap_or("").trim().to_string(); let atomic = crate::pay::xmr_to_atomic(amount).ok()?; Some((atomic, memo)) } pub fn parse_slash(raw: &str) -> Option { 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, rt: tokio::runtime::Handle) -> Result { 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, rt: tokio::runtime::Handle, screen: Screen, friends: Vec, selected: usize, me_fp: String, me_onion: String, alert: Option, status_note: Option, 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 }, Approve { payload: QrPayload }, Rotate { prompt: RotatePrompt }, Wipe { kind: WipeKind, prompt: WipePrompt }, Profile { editor: ProfileEditor }, } impl App { fn new(node: Arc, rt: tokio::runtime::Handle) -> Result { 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 { 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, 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; let mut pay_cmd = None; let mut tip_cmd = None; 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; } Some(SlashCmd::Pay { atomic, memo }) => { self.composer.clear(); pay_cmd = Some((atomic, memo)); } Some(SlashCmd::Tip { atomic, memo }) => { self.composer.clear(); tip_cmd = Some((atomic, memo)); } 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)?; } if let Some((atomic, memo)) = pay_cmd { self.send_pay(&atomic, &memo)?; } if let Some((atomic, memo)) = tip_cmd { self.send_tip(&atomic, &memo)?; } 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, 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 send_pay(&mut self, atomic: &str, memo: &str) -> Result<(), String> { let Some(friend) = self.friends.get(self.selected) else { self.status_note = Some("no friend selected".into()); return Ok(()); }; let pk = friend.pubkey.clone(); match self.rt.block_on(self.node.pay_invoice(&pk, atomic, memo)) { Ok(()) => self.status_note = Some("invoice sent".into()), Err(e) => self.status_note = Some(e), } Ok(()) } fn send_tip(&mut self, atomic: &str, memo: &str) -> Result<(), String> { let Some(friend) = self.friends.get(self.selected) else { self.status_note = Some("no friend selected".into()); return Ok(()); }; let pk = friend.pubkey.clone(); match self.rt.block_on(self.node.tip(&pk, atomic, memo)) { Ok(()) => self.status_note = Some("tip sent".into()), Err(e) => self.status_note = Some(e), } 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 = 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 /pay /tip".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 { 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::>().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::>() .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() }