use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
use huddle_core::network::NetworkMode;
use crate::app::{LobbyFocus, TuiApp};
use crate::ui::short_fp;
pub fn render_lobby(f: &mut Frame, area: Rect, app: &TuiApp) {
let peer_h: u16 = ((app.known_peers.len() as u16).clamp(1, 6)) + 2;
let status_h: u16 = if app.current_status().is_some() { 1 } else { 0 };
let mut constraints = vec![Constraint::Length(7), Constraint::Length(peer_h)];
constraints.push(Constraint::Min(5));
if status_h > 0 {
constraints.push(Constraint::Length(status_h));
}
constraints.push(Constraint::Length(3));
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
let mut idx = 0;
render_header(f, chunks[idx], app);
idx += 1;
render_known_peers(f, chunks[idx], app);
idx += 1;
render_rooms_list(f, chunks[idx], app);
idx += 1;
if status_h > 0 {
render_status(f, chunks[idx], app);
idx += 1;
}
render_hints(f, chunks[idx], app);
}
fn render_header(f: &mut Frame, area: Rect, app: &TuiApp) {
let listen = app
.listen_addresses
.iter()
.find(|a| !a.contains("127.0.0.1") && !a.contains("/ip6/"))
.cloned()
.or_else(|| app.listen_addresses.first().cloned())
.unwrap_or_else(|| "starting...".into());
let mode_label = match app.mode {
NetworkMode::Mdns => Span::styled(
"LAN (mDNS)",
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
),
NetworkMode::Direct => Span::styled(
"Direct (no broadcast)",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
};
let lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(
" huddle ",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled("· ", Style::default().fg(Color::DarkGray)),
mode_label,
]),
Line::from(Span::styled(
" decentralized rooms",
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(vec![
Span::styled(" you ", Style::default().fg(Color::DarkGray)),
Span::styled(
match app.handle.display_name() {
Some(n) if !n.is_empty() => n,
_ => "[anonymous]".into(),
},
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default()),
Span::styled(
super::display_id(app.handle.fingerprint()),
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
format!("listening on {}", listen),
Style::default().fg(Color::DarkGray),
),
Span::styled(" ", Style::default()),
nat_badge(app),
]),
];
let para = Paragraph::new(lines);
f.render_widget(para, area);
}
fn nat_badge(app: &TuiApp) -> Span<'_> {
match app.nat_status.as_deref() {
Some("reachable") => Span::styled(
"🌐 reachable",
Style::default().fg(Color::Green),
),
Some(_) => Span::styled(
"🏠 LAN only",
Style::default().fg(Color::DarkGray),
),
None => Span::styled(
"🔍 detecting…",
Style::default().fg(Color::DarkGray),
),
}
}
fn render_known_peers(f: &mut Frame, area: Rect, app: &TuiApp) {
let focused = app.lobby_focus == LobbyFocus::KnownPeers;
let border = if focused { Color::Cyan } else { Color::DarkGray };
if app.known_peers.is_empty() {
let para = Paragraph::new(Line::from(Span::styled(
" no known peers yet — press [d] to dial one.",
Style::default().fg(Color::DarkGray),
)))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border))
.padding(Padding::horizontal(1))
.title(Span::styled(
" known peers ",
Style::default().fg(border),
)),
);
f.render_widget(para, area);
return;
}
let items: Vec<ListItem> = app
.known_peers
.iter()
.enumerate()
.map(|(i, p)| {
let connected = p.connected_peer_id.is_some();
let dot = if connected { "●" } else { "○" };
let dot_style = if connected {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
let addr_style = if focused && i == app.selected_peer_idx {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let suffix = if connected {
Span::styled(
" connected",
Style::default().fg(Color::Green),
)
} else {
Span::styled(
" offline",
Style::default().fg(Color::DarkGray),
)
};
ListItem::new(Line::from(vec![
Span::styled(format!(" {} ", dot), dot_style),
Span::styled(format!("{:<40}", p.address), addr_style),
suffix,
]))
})
.collect();
let title = format!(" known peers ({}) ", app.known_peers.len());
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border))
.padding(Padding::horizontal(1))
.title(Span::styled(title, Style::default().fg(border))),
)
.highlight_style(
Style::default()
.bg(Color::Rgb(40, 40, 60))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">");
let mut state = ListState::default();
if focused {
state.select(Some(app.selected_peer_idx));
}
f.render_stateful_widget(list, area, &mut state);
}
fn render_rooms_list(f: &mut Frame, area: Rect, app: &TuiApp) {
let focused = app.lobby_focus == LobbyFocus::Rooms;
let border = if focused { Color::Cyan } else { Color::DarkGray };
if app.discovered_rooms.is_empty() {
let para = Paragraph::new(vec![
Line::from(""),
Line::from(""),
Line::from(Span::styled(
" no rooms discovered yet.",
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(Span::styled(
" press [s] to start one, or wait for others",
Style::default().fg(Color::DarkGray),
)),
Line::from(Span::styled(
match app.mode {
NetworkMode::Mdns => " on this network to appear.",
NetworkMode::Direct => " you've dialed to appear.",
},
Style::default().fg(Color::DarkGray),
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border))
.padding(Padding::horizontal(1))
.title(Span::styled(" rooms ", Style::default().fg(border))),
);
f.render_widget(para, area);
return;
}
const LOCK_W: usize = 9; const MIDDLE_W: usize = 29; const FP_W: usize = 4;
const GAP: usize = 2;
const LEADING: usize = 2;
let inner_w = (area.width as usize).saturating_sub(5);
let meta_w = LOCK_W + GAP + MIDDLE_W + GAP + FP_W;
let name_w = inner_w
.saturating_sub(LEADING + GAP + meta_w)
.max(8);
let items: Vec<ListItem> = app
.discovered_rooms
.iter()
.enumerate()
.map(|(i, r)| {
let lock = if r.encrypted { "encrypted" } else { "public" };
let lock_style = if r.encrypted {
Style::default().fg(Color::Magenta)
} else {
Style::default().fg(Color::Green)
};
let highlighted = focused && i == app.selected_room_idx;
let name_style = if highlighted {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let (middle_text, middle_style) = if r.restorable {
(
"saved · press Enter to rejoin".to_string(),
Style::default().fg(Color::Yellow),
)
} else {
(
format!("{} members", r.member_count),
Style::default().fg(Color::DarkGray),
)
};
let name_display = truncate_with_ellipsis(&r.name, name_w);
let line = Line::from(vec![
Span::raw(" ".repeat(LEADING)),
Span::styled(pad_right(&name_display, name_w), name_style),
Span::raw(" ".repeat(GAP)),
Span::styled(pad_right(lock, LOCK_W), lock_style),
Span::raw(" ".repeat(GAP)),
Span::styled(pad_right(&middle_text, MIDDLE_W), middle_style),
Span::raw(" ".repeat(GAP)),
Span::styled(
short_fp(&r.creator_fingerprint),
Style::default().fg(Color::DarkGray),
),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border))
.padding(Padding::horizontal(1))
.title(Span::styled(
format!(" rooms ({}) ", app.discovered_rooms.len()),
Style::default().fg(border),
)),
)
.highlight_style(
Style::default()
.bg(Color::Rgb(40, 40, 60))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">");
let mut state = ListState::default();
if focused {
state.select(Some(app.selected_room_idx));
}
f.render_stateful_widget(list, area, &mut state);
}
fn truncate_with_ellipsis(s: &str, max: usize) -> String {
let count = s.chars().count();
if count <= max {
return s.to_string();
}
if max == 0 {
return String::new();
}
if max == 1 {
return "…".into();
}
let mut out: String = s.chars().take(max - 1).collect();
out.push('…');
out
}
fn pad_right(s: &str, w: usize) -> String {
let count = s.chars().count();
if count >= w {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + (w - count));
out.push_str(s);
for _ in 0..(w - count) {
out.push(' ');
}
out
}
fn render_status(f: &mut Frame, area: Rect, app: &TuiApp) {
let msg = app.current_status().unwrap_or("").to_string();
let para = Paragraph::new(Line::from(Span::styled(
format!(" {}", msg),
Style::default().fg(Color::Cyan),
)));
f.render_widget(para, area);
}
fn render_hints(f: &mut Frame, area: Rect, app: &TuiApp) {
let mut spans = vec![
Span::styled(" [s]", Style::default().fg(Color::Yellow)),
Span::styled(" start ", Style::default().fg(Color::DarkGray)),
Span::styled("[d]", Style::default().fg(Color::Yellow)),
Span::styled(" dial ", Style::default().fg(Color::DarkGray)),
Span::styled("[Tab]", Style::default().fg(Color::Yellow)),
Span::styled(" switch ", Style::default().fg(Color::DarkGray)),
Span::styled("[Enter]", Style::default().fg(Color::Yellow)),
];
spans.push(Span::styled(
match app.lobby_focus {
LobbyFocus::Rooms => " join ",
LobbyFocus::KnownPeers => " reconnect ",
},
Style::default().fg(Color::DarkGray),
));
spans.extend([
Span::styled("[r]", Style::default().fg(Color::Yellow)),
Span::styled(
match app.lobby_focus {
LobbyFocus::Rooms => " refresh ",
LobbyFocus::KnownPeers => " retry ",
},
Style::default().fg(Color::DarkGray),
),
Span::styled("[?]", Style::default().fg(Color::Yellow)),
Span::styled(" help ", Style::default().fg(Color::DarkGray)),
Span::styled("[q]", Style::default().fg(Color::Yellow)),
Span::styled(" quit", Style::default().fg(Color::DarkGray)),
]);
let para = Paragraph::new(Line::from(spans)).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(para, area);
}