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::{SettingsTab, TuiApp};
use crate::ui::display_id;
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::Length(2),
Constraint::Min(0),
])
.split(area);
let title = Paragraph::new(Line::from(vec![Span::styled(
"Settings",
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
)]));
f.render_widget(title, parts[0]);
render_tab_strip(f, parts[1], app, theme);
let body = Block::default()
.borders(Borders::ALL)
.border_style(theme.border_style())
.padding(Padding::horizontal(1));
let body_inner = body.inner(parts[2]);
f.render_widget(body, parts[2]);
let lines = match app.settings_tab {
SettingsTab::Account => render_account(app, theme),
SettingsTab::Network => render_network(app, theme),
SettingsTab::Appearance => render_appearance(theme),
SettingsTab::Privacy => render_privacy(app, theme),
};
f.render_widget(Paragraph::new(lines), body_inner);
}
fn render_tab_strip(f: &mut Frame, area: Rect, app: &TuiApp, theme: &Theme) {
let tabs = [
SettingsTab::Account,
SettingsTab::Network,
SettingsTab::Appearance,
SettingsTab::Privacy,
];
let mut spans: Vec<Span> = Vec::new();
for (i, t) in tabs.iter().enumerate() {
let is_active = *t == app.settings_tab;
let style = if is_active {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
theme.dim()
};
spans.push(Span::styled(format!(" {} ", t.label()), style));
spans.push(Span::styled(format!("{}", i + 1), theme.dim()));
if i + 1 < tabs.len() {
spans.push(Span::raw(" · "));
}
}
spans.push(Span::raw(" "));
spans.push(Span::styled("Tab", theme.warn_style()));
spans.push(Span::styled(" cycle", theme.dim()));
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_account<'a>(app: &TuiApp, theme: &Theme) -> Vec<Line<'a>> {
let username = app
.handle
.display_name()
.unwrap_or_else(|| "[anonymous]".into());
let hd = display_id(app.handle.fingerprint());
let safety = app.handle.safety_code();
vec![
identity_row(theme, "username", &username),
identity_row(theme, "HD-ID", &hd),
identity_row(theme, "Safety Code", &safety),
Line::raw(""),
row(theme, "E", "edit username", "open editor".into()),
row(theme, "Q", "show QR / HD-ID", "open viewer".into()),
row(theme, "W", "replay onboarding", "press W".into()),
Line::raw(""),
Line::from(vec![Span::styled(
" (visit Profile pane to copy fields with `y`)",
theme.dim(),
)]),
]
}
fn render_network<'a>(app: &TuiApp, theme: &Theme) -> Vec<Line<'a>> {
let libp2p = app.libp2p_active();
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![Span::styled(
" huddle runs LAN discovery and the relay together by default —",
theme.dim(),
)]));
lines.push(Line::from(vec![Span::styled(
" messages ride whichever reaches the peer (always E2E encrypted):",
theme.dim(),
)]));
lines.push(Line::raw(""));
let (relay_label, relay_style) = if !app.handle.server_enabled() {
("off (--no-server)".to_string(), theme.warn_style())
} else if app.handle.server_connected() {
let door = app.handle.active_transport_label().unwrap_or("relay");
(format!("● connected · {}", door), theme.ok())
} else {
(
"○ connecting… (Tor down? try a clearnet door)".to_string(),
theme.dim(),
)
};
lines.push(Line::from(vec![
Span::styled(" Relay ", theme.dim()),
Span::styled(relay_label, relay_style),
]));
let (libp2p_label, libp2p_style) = if libp2p {
(format!("on · {}", app.mode_str()), theme.ok())
} else {
(
"off · enable LAN with the Settings toggle / --mode mdns".to_string(),
theme.dim(),
)
};
lines.push(Line::from(vec![
Span::styled(" LAN (libp2p) ", theme.dim()),
Span::styled(libp2p_label, libp2p_style),
]));
lines.push(Line::raw(""));
lines.push(Line::from(vec![Span::styled(
" transport doors (anti-censorship paths onto the relay):",
theme.dim(),
)]));
for p in app.handle.transport_profiles() {
let (mark, mstyle) = if Some(p.id) == app.handle.active_transport() {
("● active ", theme.ok())
} else if p.available() {
("· ready ", theme.dim())
} else {
(" off ", theme.dim())
};
let detail = if p.available() {
String::new()
} else {
format!(" ({})", p.reason.unwrap_or(""))
};
lines.push(Line::from(vec![
Span::styled(" ", theme.dim()),
Span::styled(mark, mstyle),
Span::styled(p.id.label(), theme.text_style()),
Span::styled(detail, theme.dim()),
]));
}
lines.push(Line::raw(""));
lines.push(Line::from(vec![Span::styled(
format!(
" override relay in {} (server_url / tor_socks)",
huddle_core::config::config_path().display()
),
theme.dim(),
)]));
lines.push(Line::raw(""));
let mdns_on = app.handle.mdns_enabled();
lines.push(row(
theme,
"M",
"LAN discovery (mDNS)",
if mdns_on {
"on · relay + LAN (restart to apply)"
} else {
"off · relay only (restart to apply)"
}
.into(),
));
if libp2p {
lines.push(Line::raw(""));
let nat = match app.nat_status.as_deref() {
Some("reachable") => "reachable",
Some("private") => "private",
_ => "detecting",
};
lines.push(Line::from(vec![
Span::styled(" reachability ", theme.dim()),
Span::styled(nat.to_string(), theme.text_style()),
]));
lines.push(Line::raw(""));
lines.push(Line::from(vec![Span::styled(
" listen addresses (cross-network dialable):",
theme.dim(),
)]));
if app.listen_addresses.is_empty() {
lines.push(Line::from(vec![Span::styled(" (binding…)", theme.dim())]));
} else {
for a in app.listen_addresses.iter().take(6) {
lines.push(Line::from(vec![Span::styled(
format!(" {}", a),
theme.text_style(),
)]));
}
}
lines.push(Line::raw(""));
let relays = huddle_core::config::load_relays().unwrap_or_default();
lines.push(Line::from(vec![Span::styled(
format!(" libp2p relays from config.toml: {}", relays.len()),
theme.dim(),
)]));
for r in &relays {
lines.push(Line::from(vec![Span::styled(
format!(" {}", r),
theme.text_style(),
)]));
}
}
lines
}
fn render_appearance<'a>(theme: &Theme) -> Vec<Line<'a>> {
vec![
Line::from(vec![
Span::styled(" theme ", theme.dim()),
Span::styled("dark", theme.text_style()),
Span::raw(" "),
Span::styled("(default; light + high-contrast in a future release)", theme.dim()),
]),
Line::raw(""),
Line::from(vec![Span::styled(
" Appearance is intentionally minimal in 0.7.8 — the theme",
theme.dim(),
)]),
Line::from(vec![Span::styled(
" module is scaffolded for v2 but only `dark` is wired up.",
theme.dim(),
)]),
]
}
fn render_privacy<'a>(app: &TuiApp, theme: &Theme) -> Vec<Line<'a>> {
let v_only = app.handle.verified_only_inbound();
let notifications = app.handle.notifications_enabled();
let update_check = app.handle.update_check_enabled();
let last_check_t = app.handle.last_update_check_at();
let last_check = if last_check_t > 0 {
relative_time(last_check_t)
} else {
"never".into()
};
let blocked_count = app.handle.list_blocked_peers().len();
let mut lines: Vec<Line> = Vec::new();
lines.push(row(
theme,
"V",
"verified-only inbound",
format!("{} (default: off)", on_off(v_only)),
));
lines.push(row(
theme,
"N",
"desktop notifications",
format!(
"{} (default: on; OS-local toasts only — never networked)",
on_off(notifications)
),
));
lines.push(row(
theme,
"U",
"update check (crates.io)",
match update_check {
Some(true) => format!("on last checked {}", last_check),
Some(false) => "off".into(),
None => "not asked yet".into(),
},
));
lines.push(row(theme, "B", "blocked peers", format!("{}", blocked_count)));
if blocked_count > 0 {
lines.push(Line::from(vec![
Span::styled(" ", theme.dim()),
Span::styled("c", theme.warn_style()),
Span::styled(" clears them all", theme.dim()),
]));
}
lines.push(Line::raw(""));
lines.push(Line::from(vec![Span::styled(
" ─── danger zone ───",
theme.dim(),
)]));
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled(" Alt+Shift+1", theme.err_style()),
Span::raw(" "),
Span::styled(format!("{:<20}", "go dark"), theme.err_style()),
Span::styled("delete account, wipe data, exit", theme.dim()),
]));
lines
}
fn row<'a>(theme: &Theme, key: &'a str, label: &'a str, value: String) -> Line<'a> {
Line::from(vec![
Span::styled(format!(" {}", key), theme.warn_style()),
Span::raw(" "),
Span::styled(format!("{:<28}", label), theme.text_style()),
Span::styled(value, theme.dim()),
])
}
fn identity_row<'a>(theme: &Theme, label: &str, value: &str) -> Line<'a> {
Line::from(vec![
Span::styled(format!(" {:<12}", label), theme.dim()),
Span::styled(value.to_string(), theme.accent_bold()),
])
}
fn on_off(b: bool) -> String {
if b {
"on".into()
} else {
"off".into()
}
}
fn relative_time(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)
}
}