use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Padding, Paragraph};
use ratatui::Frame;
use crate::app::{PeopleFocus, TuiApp};
use crate::ui::short_fp;
use crate::ui::theme::Theme;
pub fn render(f: &mut Frame, area: Rect, app: &TuiApp, theme: &Theme) {
let parts = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(0)])
.split(area);
let title = Paragraph::new(Line::from(vec![
Span::styled(
"People",
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("Tab", theme.warn_style()),
Span::styled(" switches lists · ", theme.dim()),
Span::styled("m", theme.warn_style()),
Span::styled(" message · ", theme.dim()),
Span::styled("r", theme.warn_style()),
Span::styled(" reconnect · ", theme.dim()),
Span::styled("b", theme.warn_style()),
Span::styled(" block · ", theme.dim()),
Span::styled("u", theme.warn_style()),
Span::styled(" unblock", theme.dim()),
]));
f.render_widget(title, parts[0]);
let body = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40),
Constraint::Percentage(30),
Constraint::Percentage(30),
])
.split(parts[1]);
render_known(f, body[0], app, theme);
render_verified(f, body[1], app, theme);
render_blocked(f, body[2], app, theme);
}
fn render_known(f: &mut Frame, area: Rect, app: &TuiApp, theme: &Theme) {
let focused = app.people_focus == PeopleFocus::Known;
let border = if focused {
theme.border_focus_style()
} else {
theme.border_style()
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border)
.padding(Padding::horizontal(1))
.title(Span::styled(
" Known peers ",
Style::default().fg(theme.accent),
));
let inner = block.inner(area);
f.render_widget(block, area);
if app.known_peers.is_empty() {
let p = Paragraph::new(Line::from(Span::styled(
" (no known peers — `a` to add a friend or `v` to paste an invite)",
theme.dim(),
)));
f.render_widget(p, inner);
return;
}
let mut lines: Vec<Line> = Vec::new();
for (i, p) in app.known_peers.iter().enumerate() {
let online = p.connected_peer_id.is_some();
let dot = if online { "●" } else { "○" };
let label = p.label.clone().unwrap_or_else(|| p.address.clone());
let last = p
.last_connected_at
.map(|t| format_rel(t))
.unwrap_or_else(|| "—".to_string());
let mut spans = vec![
Span::styled(
format!(" {} ", dot),
if online {
theme.ok()
} else {
theme.dim()
},
),
Span::styled(label, theme.text_style()),
Span::raw(" "),
Span::styled(p.address.clone(), theme.dim()),
Span::raw(" "),
Span::styled(last, theme.dim()),
];
if focused && i == app.selected_known_idx {
spans.insert(0, Span::styled("▸", theme.warn_style()));
} else {
spans.insert(0, Span::raw(" "));
}
lines.push(Line::from(spans));
}
f.render_widget(Paragraph::new(lines), inner);
}
fn render_verified(f: &mut Frame, area: Rect, app: &TuiApp, theme: &Theme) {
let focused = app.people_focus == PeopleFocus::Verified;
let border = if focused {
theme.border_focus_style()
} else {
theme.border_style()
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border)
.padding(Padding::horizontal(1))
.title(Span::styled(
" Verified ",
Style::default().fg(theme.success),
));
let inner = block.inner(area);
f.render_widget(block, area);
let verified = app.handle.list_verified_peers();
if verified.is_empty() {
let p = Paragraph::new(Line::from(Span::styled(
" (no verified peers yet — Ctrl+V on a chat starts SAS)",
theme.dim(),
)));
f.render_widget(p, inner);
return;
}
let mut lines: Vec<Line> = Vec::new();
for fp in verified.iter() {
let label = app
.handle
.lookup_username(fp)
.unwrap_or_else(|| "[anonymous]".into());
lines.push(Line::from(vec![
Span::styled(" ✓ ", theme.ok()),
Span::styled(label, theme.text_style()),
Span::raw(" "),
Span::styled(short_fp(fp).to_uppercase(), theme.dim()),
]));
}
f.render_widget(Paragraph::new(lines), inner);
}
fn render_blocked(f: &mut Frame, area: Rect, app: &TuiApp, theme: &Theme) {
let focused = app.people_focus == PeopleFocus::Blocked;
let border = if focused {
theme.border_focus_style()
} else {
theme.border_style()
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border)
.padding(Padding::horizontal(1))
.title(Span::styled(" Blocked ", Style::default().fg(theme.error)));
let inner = block.inner(area);
f.render_widget(block, area);
let blocked = app.handle.list_blocked_peers();
if blocked.is_empty() {
let p = Paragraph::new(Line::from(Span::styled(
" (no blocked peers)",
theme.dim(),
)));
f.render_widget(p, inner);
return;
}
let mut lines: Vec<Line> = Vec::new();
for (i, fp) in blocked.iter().enumerate() {
let mut spans = vec![
Span::styled(" ⛔ ", theme.err_style()),
Span::styled(short_fp(fp).to_uppercase(), theme.text_style()),
];
if focused && i == app.selected_blocked_idx {
spans.insert(0, Span::styled("▸", theme.warn_style()));
} else {
spans.insert(0, Span::raw(" "));
}
lines.push(Line::from(spans));
}
f.render_widget(Paragraph::new(lines), inner);
}
fn format_rel(unix_secs: i64) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let dt = now - unix_secs;
if dt < 60 {
format!("{}s ago", dt.max(0))
} else if dt < 3600 {
format!("{}m ago", dt / 60)
} else if dt < 86400 {
format!("{}h ago", dt / 3600)
} else {
format!("{}d ago", dt / 86400)
}
}