hack-house/hh/src/ui.rs
leetcrypt 14aa369fb2 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>
2026-05-30 13:57:07 -07:00

122 lines
4.4 KiB
Rust

//! 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));
}
}