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:
+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