feat(hh): ratatui TUI client — chat, live roster, themes
- Connect subcommand: SRP auth then a ratatui UI over tokio + crossterm. - Async ws (tokio-tungstenite); reader task decrypts/parses frames into events. - Panes: top bar (e2e + house N/cap), chat scrollback, roster (self marked ⛧), input box. Undecryptable frames surface as a system line, not a silent drop. - Themes (vestments) via TOML --theme; default occult-monochrome + neon. - Verified live in tmux: render, chat round-trip, roster, join/leave. - Adds fernet python->rust interop regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+163
@@ -0,0 +1,163 @@
|
||||
//! TUI application state, network event model, and the async run loop.
|
||||
|
||||
use crate::net::{self, Session};
|
||||
use crate::theme::Theme;
|
||||
use crate::ui;
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
use crossterm::execute;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMsg;
|
||||
|
||||
/// One rendered chat row.
|
||||
#[derive(Clone)]
|
||||
pub struct ChatLine {
|
||||
pub ts: String,
|
||||
pub username: String,
|
||||
pub text: String,
|
||||
pub system: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct User {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// Decoded events arriving from the websocket reader task.
|
||||
pub enum Net {
|
||||
Init { lines: Vec<ChatLine>, users: Vec<User> },
|
||||
Message(ChatLine),
|
||||
Roster { users: Vec<User>, capacity: usize },
|
||||
Joined(String),
|
||||
Left(String),
|
||||
Closed,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub me: String,
|
||||
pub lines: Vec<ChatLine>,
|
||||
pub users: Vec<User>,
|
||||
pub capacity: usize,
|
||||
pub input: String,
|
||||
pub connected: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(me: String) -> Self {
|
||||
Self {
|
||||
me,
|
||||
lines: Vec::new(),
|
||||
users: Vec::new(),
|
||||
capacity: 0,
|
||||
input: String::new(),
|
||||
connected: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn sys(&mut self, text: impl Into<String>) {
|
||||
self.lines.push(ChatLine {
|
||||
ts: String::new(),
|
||||
username: String::new(),
|
||||
text: text.into(),
|
||||
system: true,
|
||||
});
|
||||
}
|
||||
|
||||
fn apply(&mut self, n: Net) {
|
||||
match n {
|
||||
Net::Init { lines, users } => {
|
||||
self.lines = lines;
|
||||
self.users = users;
|
||||
self.connected = true;
|
||||
self.sys(format!("joined as {} ⛧", self.me));
|
||||
}
|
||||
Net::Message(l) => self.lines.push(l),
|
||||
Net::Roster { users, capacity } => {
|
||||
self.users = users;
|
||||
self.capacity = capacity;
|
||||
}
|
||||
Net::Joined(name) => self.sys(format!("{name} entered the house")),
|
||||
Net::Left(uid) => {
|
||||
if let Some(p) = self.users.iter().position(|u| u.user_id == uid) {
|
||||
let name = self.users.remove(p).username;
|
||||
self.sys(format!("{name} left"));
|
||||
}
|
||||
}
|
||||
Net::Closed => {
|
||||
self.connected = false;
|
||||
self.sys("connection closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate already done; connect the websocket and drive the UI.
|
||||
pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||
let ws = net::connect(&session).await?;
|
||||
let (mut write, read) = ws.split();
|
||||
let (tx, mut rx) = unbounded_channel::<Net>();
|
||||
tokio::spawn(net::reader(read, session.room.clone(), tx));
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = std::io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let mut term = Terminal::new(CrosstermBackend::new(stdout))?;
|
||||
|
||||
let mut app = App::new(session.username.clone());
|
||||
let mut events = EventStream::new();
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(200));
|
||||
|
||||
let result = loop {
|
||||
if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) {
|
||||
break Err(e.into());
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
maybe = events.next() => {
|
||||
match maybe {
|
||||
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
|
||||
match (k.modifiers, k.code) {
|
||||
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break Ok(()),
|
||||
(_, KeyCode::Esc) => break Ok(()),
|
||||
(_, KeyCode::Enter) => {
|
||||
let line = app.input.trim().to_string();
|
||||
app.input.clear();
|
||||
if !line.is_empty() && app.connected {
|
||||
let ct = session.room.encrypt(line.as_bytes());
|
||||
if write.send(WsMsg::Text(ct)).await.is_err() {
|
||||
app.connected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
(_, KeyCode::Backspace) => { app.input.pop(); }
|
||||
(_, KeyCode::Char(c)) => app.input.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => break Err(e.into()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
net = rx.recv() => {
|
||||
match net {
|
||||
Some(n) => app.apply(n),
|
||||
None => break Ok(()),
|
||||
}
|
||||
}
|
||||
_ = tick.tick() => {}
|
||||
}
|
||||
};
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(term.backend_mut(), LeaveAlternateScreen)?;
|
||||
term.show_cursor()?;
|
||||
result
|
||||
}
|
||||
@@ -235,3 +235,17 @@ mod tests {
|
||||
assert_eq!(hex::encode(&ch.h_amk), HAMK_HEX, "H_AMK mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fernet_interop {
|
||||
// Token produced by Python `cryptography` Fernet with key = urlsafe_b64(0x42*32).
|
||||
const KEY: &str = "PulnLblVVdOu6iB0rjW8rQ2U2pwgsky3eod8I2OhLdE=";
|
||||
const TOK: &str = "gAAAAABqG0ufNzHGkbfMWh4-46KVthUTnXUN9jVvGJ2UxklQFdBMIqBCMXmTmciEnB14kl_H613IOYm5w22bebVUhpu9ULuLf1fjq4jjaIK_ZHZNwCyqjy0=";
|
||||
|
||||
#[test]
|
||||
fn rust_decrypts_python_fernet() {
|
||||
let f = fernet::Fernet::new(KEY).unwrap();
|
||||
let pt = f.decrypt(TOK).expect("rust must decrypt python fernet token");
|
||||
assert_eq!(pt, b"room key interop test");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
//! speaks the same SRP / Fernet dialect as the Python Sanic server, plus the
|
||||
//! ratatui UI built on top of that proven foundation.
|
||||
|
||||
mod app;
|
||||
mod crypto;
|
||||
mod net;
|
||||
mod theme;
|
||||
mod ui;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
@@ -23,6 +27,23 @@ struct Cli {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Join a house: SRP auth then launch the ratatui UI.
|
||||
Connect {
|
||||
ip: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
#[arg(long)]
|
||||
password: String,
|
||||
#[arg(long, default_value_t = false)]
|
||||
no_tls: bool,
|
||||
#[arg(long, default_value_t = false)]
|
||||
insecure: bool,
|
||||
/// Path to a theme (vestments) TOML file.
|
||||
#[arg(long)]
|
||||
theme: Option<String>,
|
||||
},
|
||||
/// Debug: print the derived room Fernet key for a password + room_salt(hex).
|
||||
Roomkey { password: String, room_salt_hex: String },
|
||||
/// Run the offline SRP golden-vector self-test.
|
||||
Selftest,
|
||||
/// Debug: compute A and M from explicit a/salt/B hex (parity check vs python).
|
||||
@@ -51,6 +72,34 @@ enum Cmd {
|
||||
|
||||
fn main() -> Result<()> {
|
||||
match Cli::parse().cmd {
|
||||
Cmd::Connect {
|
||||
ip,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
no_tls,
|
||||
insecure,
|
||||
theme,
|
||||
} => {
|
||||
let session = net::authenticate(&ip, port, &user, &password, no_tls, insecure)?;
|
||||
let theme = match theme {
|
||||
Some(p) => theme::Theme::load(&p)?,
|
||||
None => theme::Theme::default(),
|
||||
};
|
||||
tokio::runtime::Runtime::new()?.block_on(app::run(session, theme))
|
||||
}
|
||||
Cmd::Roomkey { password, room_salt_hex } => {
|
||||
let salt = hex::decode(room_salt_hex)?;
|
||||
let f = crypto::room_fernet(password.as_bytes(), &salt)?;
|
||||
// fernet crate doesn't expose the key; re-derive + print the b64 key.
|
||||
use base64::Engine;
|
||||
let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&salt), password.as_bytes());
|
||||
let mut okm = [0u8; 32];
|
||||
hk.expand(b"cmd-chat-room-key", &mut okm).unwrap();
|
||||
println!("{}", base64::engine::general_purpose::URL_SAFE.encode(okm));
|
||||
let _ = f;
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Selftest => selftest(),
|
||||
Cmd::Srpm {
|
||||
a_hex,
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
//! SRP authentication (blocking, one-shot) + async websocket transport and the
|
||||
//! reader task that decrypts/parses server frames into `Net` events.
|
||||
|
||||
use crate::app::{ChatLine, Net, User};
|
||||
use crate::crypto;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMsg;
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
|
||||
type Ws = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
|
||||
pub struct Session {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub room: Arc<fernet::Fernet>,
|
||||
pub ws_url: String,
|
||||
pub no_tls: bool,
|
||||
pub insecure: bool,
|
||||
}
|
||||
|
||||
/// Full SRP handshake against the Sanic server. Returns a ready Session
|
||||
/// (room key derived, ws url built) but does not open the websocket.
|
||||
pub fn authenticate(
|
||||
ip: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
password: &str,
|
||||
no_tls: bool,
|
||||
insecure: bool,
|
||||
) -> Result<Session> {
|
||||
let scheme = if no_tls { "http" } else { "https" };
|
||||
let base = format!("{scheme}://{ip}:{port}");
|
||||
let http = reqwest::blocking::Client::builder()
|
||||
.danger_accept_invalid_certs(insecure && !no_tls)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
let client = crypto::SrpClient::new(crypto::SRP_IDENTITY, password.as_bytes());
|
||||
|
||||
let init: Value = http
|
||||
.post(format!("{base}/srp/init"))
|
||||
.json(&json!({ "username": user, "A": STANDARD.encode(client.a_bytes()) }))
|
||||
.send()
|
||||
.context("srp/init request")?
|
||||
.error_for_status()
|
||||
.context("srp/init rejected (name taken or house full?)")?
|
||||
.json()?;
|
||||
|
||||
let user_id = init["user_id"].as_str().context("no user_id")?.to_string();
|
||||
let b = STANDARD.decode(init["B"].as_str().context("no B")?)?;
|
||||
let salt = STANDARD.decode(init["salt"].as_str().context("no salt")?)?;
|
||||
let room_salt = STANDARD.decode(init["room_salt"].as_str().context("no room_salt")?)?;
|
||||
|
||||
let ch = client.process_challenge(&salt, &b)?;
|
||||
|
||||
let verify: Value = http
|
||||
.post(format!("{base}/srp/verify"))
|
||||
.json(&json!({ "user_id": user_id, "username": user, "M": STANDARD.encode(&ch.m) }))
|
||||
.send()
|
||||
.context("srp/verify request")?
|
||||
.error_for_status()
|
||||
.context("srp/verify rejected — wrong room password?")?
|
||||
.json()?;
|
||||
|
||||
let server_hamk = STANDARD.decode(verify["H_AMK"].as_str().context("no H_AMK")?)?;
|
||||
anyhow::ensure!(server_hamk == ch.h_amk, "server identity check failed (H_AMK) — MITM?");
|
||||
let ws_token = verify["ws_token"].as_str().context("no ws_token")?;
|
||||
|
||||
let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?;
|
||||
let ws_scheme = if no_tls { "ws" } else { "wss" };
|
||||
let ws_url =
|
||||
format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
|
||||
|
||||
Ok(Session {
|
||||
user_id,
|
||||
username: user.to_string(),
|
||||
room: Arc::new(fernet),
|
||||
ws_url,
|
||||
no_tls,
|
||||
insecure,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn connect(session: &Session) -> Result<Ws> {
|
||||
if !session.no_tls && session.insecure {
|
||||
anyhow::bail!(
|
||||
"self-signed (insecure) wss is not yet wired in the TUI — \
|
||||
use --no-tls or a trusted certificate"
|
||||
);
|
||||
}
|
||||
let (ws, _) = tokio_tungstenite::connect_async(&session.ws_url)
|
||||
.await
|
||||
.context("websocket connect")?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
fn parse_users(v: &Value) -> Vec<User> {
|
||||
v.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|u| {
|
||||
Some(User {
|
||||
user_id: u["user_id"].as_str()?.to_string(),
|
||||
username: u["username"].as_str().unwrap_or("?").to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Decode one stored/broadcast message object into a ChatLine, or None to skip
|
||||
/// (empty text, decrypt failure, or a file-transfer control frame).
|
||||
fn decode_msg(room: &fernet::Fernet, m: &Value) -> Option<ChatLine> {
|
||||
let ct = m["text"].as_str()?;
|
||||
if ct.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (text, system) = match room.decrypt(ct) {
|
||||
Ok(pt) => {
|
||||
let t = String::from_utf8_lossy(&pt).to_string();
|
||||
if t.starts_with("{\"_ft\":") {
|
||||
return None; // file-transfer control frame — handled elsewhere (P5)
|
||||
}
|
||||
(t, false)
|
||||
}
|
||||
// Wrong room key / corrupt frame — surface, don't crash or hide silently.
|
||||
Err(_) => ("[unreadable — wrong room password?]".to_string(), true),
|
||||
};
|
||||
let stamp = m["timestamp"].as_str().unwrap_or("");
|
||||
let ts = if stamp.len() >= 19 { stamp[11..19].to_string() } else { String::new() };
|
||||
Some(ChatLine {
|
||||
ts,
|
||||
username: m["username"].as_str().unwrap_or("?").to_string(),
|
||||
text,
|
||||
system,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read websocket frames forever, forwarding decoded `Net` events to the UI.
|
||||
pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungstenite::tungstenite::Error>> + Unpin, room: Arc<fernet::Fernet>, tx: UnboundedSender<Net>) {
|
||||
while let Some(frame) = read.next().await {
|
||||
let txt = match frame {
|
||||
Ok(WsMsg::Text(t)) => t,
|
||||
Ok(WsMsg::Ping(_)) | Ok(WsMsg::Pong(_)) => continue,
|
||||
_ => break,
|
||||
};
|
||||
let v: Value = match serde_json::from_str(&txt) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let sent = match v["type"].as_str().unwrap_or("") {
|
||||
"init" => {
|
||||
let lines = v["messages"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|m| decode_msg(&room, m))
|
||||
.collect();
|
||||
tx.send(Net::Init { lines, users: parse_users(&v["users"]) })
|
||||
}
|
||||
"message" => match decode_msg(&room, &v["data"]) {
|
||||
Some(l) => tx.send(Net::Message(l)),
|
||||
None => Ok(()),
|
||||
},
|
||||
"roster" => tx.send(Net::Roster {
|
||||
users: parse_users(&v["users"]),
|
||||
capacity: v["capacity"].as_u64().unwrap_or(0) as usize,
|
||||
}),
|
||||
"user_joined" => tx.send(Net::Joined(v["username"].as_str().unwrap_or("?").to_string())),
|
||||
"user_left" => tx.send(Net::Left(v["user_id"].as_str().unwrap_or("").to_string())),
|
||||
_ => Ok(()),
|
||||
};
|
||||
if sent.is_err() {
|
||||
return; // UI gone
|
||||
}
|
||||
}
|
||||
let _ = tx.send(Net::Closed);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Loadable colour/layout themes ("vestments"). Default is the churchofmalware
|
||||
//! occult-monochrome: black ground, white/grey ink, ⛧ accents. Override with a
|
||||
//! TOML file via `--theme <path>`.
|
||||
|
||||
use ratatui::style::Color;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Theme {
|
||||
pub name: String,
|
||||
pub border: Color,
|
||||
pub title: Color,
|
||||
pub accent: Color,
|
||||
pub dim: Color,
|
||||
pub me: Color,
|
||||
pub other: Color,
|
||||
pub system: Color,
|
||||
pub input: Color,
|
||||
pub roster_me: Color,
|
||||
/// Width of the roster column.
|
||||
pub roster_width: u16,
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "crypt".into(),
|
||||
border: Color::DarkGray,
|
||||
title: Color::White,
|
||||
accent: Color::White,
|
||||
dim: Color::DarkGray,
|
||||
me: Color::White,
|
||||
other: Color::Gray,
|
||||
system: Color::DarkGray,
|
||||
input: Color::White,
|
||||
roster_me: Color::White,
|
||||
roster_width: 22,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn load(path: &str) -> anyhow::Result<Self> {
|
||||
let s = std::fs::read_to_string(path)?;
|
||||
Ok(toml::from_str(&s)?)
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
//! ratatui rendering — top bar, chat, roster, input.
|
||||
|
||||
use crate::app::{App, ChatLine};
|
||||
use crate::theme::Theme;
|
||||
use ratatui::layout::{Constraint, Layout, Position};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, List, ListItem, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(f.area());
|
||||
|
||||
draw_top(f, rows[0], app, theme);
|
||||
|
||||
let body = Layout::horizontal([Constraint::Min(1), Constraint::Length(theme.roster_width)])
|
||||
.split(rows[1]);
|
||||
draw_chat(f, body[0], app, theme);
|
||||
draw_roster(f, body[1], app, theme);
|
||||
draw_input(f, rows[2], app, theme);
|
||||
}
|
||||
|
||||
fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||
let cap = if app.capacity > 0 { app.capacity } else { app.users.len() };
|
||||
let status = if app.connected { "🔒 e2e" } else { "✖ closed" };
|
||||
let bar = Line::from(vec![
|
||||
Span::styled(
|
||||
" ⛧ hack-house ⛧ ",
|
||||
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!("· {status} "), Style::default().fg(theme.dim)),
|
||||
Span::styled(
|
||||
format!("· house {}/{} ", app.users.len(), cap),
|
||||
Style::default().fg(theme.title),
|
||||
),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(bar), area);
|
||||
}
|
||||
|
||||
fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
|
||||
if l.system {
|
||||
return Line::from(Span::styled(
|
||||
format!(" ⛧ {}", l.text),
|
||||
Style::default().fg(theme.system).add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
}
|
||||
let name_color = if l.username == app.me { theme.me } else { theme.other };
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)),
|
||||
Span::styled(
|
||||
l.username.clone(),
|
||||
Style::default().fg(name_color).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(": ", Style::default().fg(theme.dim)),
|
||||
Span::styled(l.text.as_str(), Style::default().fg(theme.title)),
|
||||
])
|
||||
}
|
||||
|
||||
fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||
let visible = area.height.saturating_sub(2) as usize;
|
||||
let start = app.lines.len().saturating_sub(visible);
|
||||
let lines: Vec<Line> = app.lines[start..].iter().map(|l| fmt_line(l, app, theme)).collect();
|
||||
let chat = Paragraph::new(lines)
|
||||
.block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(Span::styled(" chat ", Style::default().fg(theme.title))),
|
||||
)
|
||||
.wrap(Wrap { trim: false });
|
||||
f.render_widget(chat, area);
|
||||
}
|
||||
|
||||
fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||
let items: Vec<ListItem> = app
|
||||
.users
|
||||
.iter()
|
||||
.map(|u| {
|
||||
let me = u.username == app.me;
|
||||
let mark = if me { "⛧" } else { "•" };
|
||||
let color = if me { theme.roster_me } else { theme.other };
|
||||
ListItem::new(Line::from(Span::styled(
|
||||
format!(" {mark} {}", u.username),
|
||||
Style::default().fg(color),
|
||||
)))
|
||||
})
|
||||
.collect();
|
||||
let roster = List::new(items).block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(Span::styled(" coven ", Style::default().fg(theme.title))),
|
||||
);
|
||||
f.render_widget(roster, area);
|
||||
}
|
||||
|
||||
fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||
let input = Paragraph::new(Line::from(vec![
|
||||
Span::styled("> ", Style::default().fg(theme.accent)),
|
||||
Span::styled(app.input.as_str(), Style::default().fg(theme.input)),
|
||||
]))
|
||||
.block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(Span::styled(
|
||||
" message · enter send · esc quit ",
|
||||
Style::default().fg(theme.dim),
|
||||
)),
|
||||
);
|
||||
f.render_widget(input, area);
|
||||
|
||||
// Cursor after the "> " prompt + current input.
|
||||
let cx = area.x + 3 + app.input.chars().count() as u16;
|
||||
let cy = area.y + 1;
|
||||
if cx < area.x + area.width.saturating_sub(1) {
|
||||
f.set_cursor_position(Position::new(cx, cy));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user