use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Row, Table};
use ratatui::Frame;
use crate::tui::panes::tld::{filter_catalog, TldState};
use crate::tui::theme::Theme;
use crate::tui::widgets::{kv, panel, scroll_to};
use seer_core::TldInfo;
pub fn render(
f: &mut Frame,
area: Rect,
theme: &Theme,
state: &TldState,
loaded: Option<&TldInfo>,
loading: bool,
editing: Option<&str>,
) {
let block = panel::block(theme, "TLD Browser", theme.maroon, false);
let inner = block.inner(area);
f.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(8), ])
.split(inner);
let effective_filter = editing.unwrap_or(&state.filter);
let list = filter_catalog(effective_filter);
let sel = if list.is_empty() {
0
} else {
state.sel.min(list.len() - 1)
};
let filter_line = if let Some(buf) = editing {
Line::from(vec![
Span::styled("filter: ", Style::default().fg(theme.overlay0)),
Span::styled(format!("{buf}▏"), Style::default().fg(theme.text)),
Span::styled(
format!(" {} match{}", list.len(), plural(list.len())),
Style::default().fg(theme.overlay0),
),
])
} else if state.filter.is_empty() {
Line::from(vec![
Span::styled("filter: ", Style::default().fg(theme.overlay0)),
Span::styled(
format!("(all {} TLDs)", list.len()),
Style::default().fg(theme.overlay0),
),
])
} else {
Line::from(vec![
Span::styled("filter: ", Style::default().fg(theme.overlay0)),
Span::styled(state.filter.clone(), Style::default().fg(theme.sky)),
Span::styled(
format!(" {} match{}", list.len(), plural(list.len())),
Style::default().fg(theme.overlay0),
),
])
};
f.render_widget(Paragraph::new(filter_line), chunks[0]);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"/ filter · j/k move · g/G top/bottom · ↵ load details",
Style::default().fg(theme.overlay0),
))),
chunks[1],
);
if list.is_empty() {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"no TLDs match — adjust the filter",
Style::default().fg(theme.overlay0),
))),
chunks[2],
);
} else {
let rows = list.iter().enumerate().map(|(i, tld)| {
let style = if i == sel {
Style::default().fg(theme.text).bg(theme.surface0)
} else {
Style::default().fg(theme.subtext)
};
Row::new(vec![Line::from(format!(".{tld}"))]).style(style)
});
let table = Table::new(rows, [Constraint::Percentage(100)]).column_spacing(0);
let mut ts = scroll_to(Some(sel));
f.render_stateful_widget(table, chunks[2], &mut ts);
}
let detail_block = panel::block(theme, "Registry", theme.maroon, false);
let detail_inner = detail_block.inner(chunks[3]);
f.render_widget(detail_block, chunks[3]);
let selected_dotted = list.get(sel).map(|t| format!(".{t}")).unwrap_or_default();
if loading {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!("querying {selected_dotted}…"),
Style::default().fg(theme.overlay),
))),
detail_inner,
);
return;
}
let detail_matches = loaded.is_some_and(|t| {
let want = selected_dotted.trim_start_matches('.');
t.tld.trim_start_matches('.') == want
});
if let (true, Some(t)) = (detail_matches, loaded) {
let dash = || "—".to_string();
let rows: Vec<(String, String)> = vec![
("tld".into(), t.tld.clone()),
("type".into(), t.tld_type.clone()),
("whois".into(), t.whois_server.clone().unwrap_or_else(dash)),
("rdap".into(), t.rdap_url.clone().unwrap_or_else(dash)),
(
"registry".into(),
t.registry_url.clone().unwrap_or_else(dash),
),
];
kv::render(f, detail_inner, theme, theme.maroon, &rows);
} else {
let hint = if selected_dotted.is_empty() {
"—".to_string()
} else {
format!("press ↵ to load details for {selected_dotted}")
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(
hint,
Style::default().fg(theme.overlay0),
))),
detail_inner,
);
}
}
fn plural(n: usize) -> &'static str {
if n == 1 {
""
} else {
"es"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::panes::tld::TldState;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use seer_core::TldInfo;
fn buf_text(buf: &ratatui::buffer::Buffer) -> String {
let a = buf.area();
let mut s = String::new();
for y in 0..a.height {
for x in 0..a.width {
s.push_str(buf[(x, y)].symbol());
}
}
s
}
fn com_info() -> TldInfo {
TldInfo {
tld: "com".into(),
whois_server: Some("whois.verisign-grs.com".into()),
rdap_url: None,
registry_url: None,
tld_type: "generic".into(),
}
}
fn draw(state: &TldState, loaded: Option<&TldInfo>, editing: Option<&str>) -> String {
let theme = Theme::frappe();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| render(f, f.area(), &theme, state, loaded, false, editing))
.unwrap();
buf_text(terminal.backend().buffer())
}
#[test]
fn lists_many_tlds_and_count() {
let state = TldState::default();
let text = draw(&state, None, None);
assert!(text.contains("all "), "should show the all-TLDs count");
assert!(text.contains("TLDs"), "should label the count");
assert!(text.contains(".com"), "list should contain .com");
}
#[test]
fn live_filter_buffer_narrows_the_list() {
let state = TldState::default();
let text = draw(&state, None, Some("shop"));
assert!(text.contains("filter: shop"), "shows the edit buffer");
assert!(text.contains(".shop"), "filtered list shows .shop");
assert!(!text.contains(".com "), "unrelated TLDs filtered out");
}
#[test]
fn detail_shows_loaded_tld_matching_selection() {
let state = TldState::default(); let info = com_info();
let text = draw(&state, Some(&info), None);
assert!(
text.contains("whois.verisign-grs.com"),
"detail panel should render the loaded TLD's whois server"
);
}
#[test]
fn detail_hint_when_nothing_loaded() {
let state = TldState::default();
let text = draw(&state, None, None);
assert!(
text.contains("press ↵ to load details"),
"should prompt to load details when none are loaded"
);
}
#[test]
fn empty_filter_match_shows_placeholder() {
let state = TldState::default();
let text = draw(&state, None, Some("zzzznotatld"));
assert!(
text.contains("no TLDs match"),
"empty match set should show a placeholder"
);
}
}