From 82634db1423666daa54082df310c5e13566ca05b Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Thu, 10 Sep 2026 13:01:40 -0400 Subject: [PATCH] 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). --- README.md | 12 +- src/tui.rs | 500 ++++++++++++++++++++++++++++++++++++++------ tests/tui_chrome.rs | 156 ++++++++++++++ 3 files changed, 604 insertions(+), 64 deletions(-) create mode 100644 tests/tui_chrome.rs diff --git a/README.md b/README.md index 938ff58..8583124 100644 --- a/README.md +++ b/README.md @@ -113,15 +113,21 @@ A friend is an ed25519 pubkey (`UNIQUE(pubkey)`). The `.onion` on that row is on ## 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 | |---|---| +| `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 | | `F3` | Paste a friend’s payload | | `F4` | Rotate **onion** (locator only) | -| `↑` / `↓` | Roster | -| Enter | Send (composer) | +| Enter | Run `/wipe` or `/wipe-all` from the composer | +| `Esc` | Close overlay / back to Main / clear composer | | `Ctrl-Q` | Quit | -| Esc | Cancel overlay / clear composer | ## F2 QR diff --git a/src/tui.rs b/src/tui.rs index b9ab8fd..437e544 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -2,15 +2,23 @@ use std::io; use std::sync::Arc; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use ratatui::layout::{Constraint, Layout}; -use ratatui::style::{Color, Style}; -use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; +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; +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" } @@ -23,6 +31,80 @@ 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 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)] pub enum WipeKind { Messages, @@ -178,6 +260,9 @@ struct App { alert: Option, status_note: Option, composer: String, + focus: Pane, + help_open: bool, + chat_scroll: u16, } enum Screen { @@ -203,6 +288,9 @@ impl App { alert: None, status_note: None, 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, 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; match &mut self.screen { Screen::Main => match key.code { - KeyCode::Up => { - self.selected = self.selected.saturating_sub(1); - } - KeyCode::Down => { - if !self.friends.is_empty() { - self.selected = (self.selected + 1).min(self.friends.len() - 1); - } - } + 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 { @@ -250,7 +346,9 @@ impl App { } KeyCode::Esc => self.composer.clear(), KeyCode::Backspace => { - self.composer.pop(); + if self.focus == Pane::Composer { + self.composer.pop(); + } } KeyCode::Enter => { if let Some(kind) = parse_slash(&self.composer) { @@ -263,7 +361,7 @@ impl App { } } 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) } + 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 => { @@ -402,6 +569,7 @@ impl App { } else { self.selected = self.selected.min(self.friends.len() - 1); } + self.chat_scroll = u16::MAX; Ok(()) } @@ -414,87 +582,260 @@ impl App { Screen::Rotate { prompt } => draw_rotate(f, 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) { 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::Min(1), - Constraint::Length(1), + Constraint::Length(banner_h), + Constraint::Length(alert_h), + Constraint::Min(3), + Constraint::Length(3), Constraint::Length(1), ]) .split(area); - let body = if self.alert.is_some() { - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(cols[0]) - } else { - Layout::vertical([Constraint::Length(0), Constraint::Min(1)]).split(cols[0]) - }; + + 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(Color::Red)), - body[0], + Paragraph::new(msg.as_str()).style(Style::default().fg(C_ALERT)), + cols[1], ); } + 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 = self .friends .iter() - .enumerate() - .map(|(i, friend)| { + .map(|friend| { let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint); let fp = truncate_fp(&friend.fingerprint); - let stale = if friend.last_connect_ok { "" } else { " stale" }; - let mark = if i == self.selected { ">" } else { " " }; - ListItem::new(format!("{mark} {name} {fp}{stale}")) + 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).block(Block::default().borders(Borders::ALL).title("roster")); - f.render_widget(roster, panes[0]); + 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_text = if let Some(friend) = self.friends.get(self.selected) { - let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint); - format!("{name}\n\nchat arrives in M3") - } else { - "no friends yet\nF3 paste a QR to add one".into() + 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 }) - .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]); - 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() { "/wipe /wipe-all".to_string() } else { self.composer.clone() }; - f.render_widget(Paragraph::new(cmd), cols[1]); - f.render_widget(Paragraph::new(status), cols[2]); + 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 ? 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) { - 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( Paragraph::new(body) + .style(Style::default().fg(C_TEXT)) .wrap(Wrap { trim: false }) - .block(Block::default().borders(Borders::ALL).title("F2 share")), + .block(themed_block("(o) share")), 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>) { 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(Block::default().borders(Borders::ALL).title("F3 paste QR")), + .block(themed_block("(o) paste QR")), f.area(), ); } @@ -518,7 +865,9 @@ fn draw_approve(f: &mut Frame, payload: &QrPayload) { payload.onion ); 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(), ); } @@ -527,26 +876,55 @@ 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(Block::default().borders(Borders::ALL).title("F4 rotate")), + .block(themed_block("(o) rotate locator")), f.area(), ); } fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) { let title = match kind { - WipeKind::Messages => "/wipe", - WipeKind::All => "/wipe-all", + 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(Block::default().borders(Borders::ALL).title(title)), + .block(themed_block(title)), 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 { let s: String = fp.chars().take(8).collect(); format!("{s}…") diff --git a/tests/tui_chrome.rs b/tests/tui_chrome.rs new file mode 100644 index 0000000..913833d --- /dev/null +++ b/tests/tui_chrome.rs @@ -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); +}