- 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>
122 lines
4.4 KiB
Rust
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));
|
|
}
|
|
}
|