use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
use ratatui::Frame;
use seer_core::output::sanitize_line;
use crate::tui::action::{EditTarget, Focus, InputMode, LensData, LensKey, LensState};
use crate::tui::app::{App, SPIN};
use crate::tui::lenses::{self};
use crate::tui::line_editor::LineEditor;
use crate::tui::theme::Theme;
use crate::tui::widgets::{inspector, page, panel};
fn field_buf(mode: &InputMode, target: EditTarget) -> Option<&LineEditor> {
match mode {
InputMode::Field { target: t, buf } if *t == target => Some(buf),
_ => None,
}
}
fn status_prompt(app: &App, target: EditTarget) -> Option<String> {
let follow = &app.panes.follow;
match target {
EditTarget::FollowInterval => {
Some(format!("interval seconds (now {})> ", follow.interval_secs))
}
EditTarget::FollowCount => Some(format!("check count (now {})> ", follow.count)),
EditTarget::BulkPath => Some("domains file> ".to_string()),
EditTarget::WatchAdd => Some("watch add> ".to_string()),
EditTarget::Target
| EditTarget::DiffB
| EditTarget::BulkDomains
| EditTarget::TldFilter
| EditTarget::LensFilter
| EditTarget::Switcher => None,
}
}
pub fn view(f: &mut Frame, app: &App, theme: &Theme) {
let area = f.area();
f.render_widget(
Block::default().style(Style::default().bg(theme.base).fg(theme.text)),
area,
);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
top_bar(f, rows[0], app, theme);
strip_groups(f, rows[1], app, theme);
strip_lenses(f, rows[2], app, theme);
main_pane(f, rows[3], app, theme);
status_or_command(f, rows[4], app, theme);
if let Some(buf) = field_buf(&app.input_mode, EditTarget::Switcher) {
switcher(f, area, buf, theme);
}
if app.help {
help_overlay(f, area, app, theme);
}
}
fn top_bar(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let domain = app.domain.as_deref().unwrap_or("(no target)");
let target = match &app.input_mode {
InputMode::Field {
target: EditTarget::Target,
buf,
} => format!("⌕ {}", buf.with_caret("▏")),
_ => format!("⌕ {domain}"),
};
let ip = match app.state_of(app.lens) {
LensState::Loading => format!("{} resolving…", SPIN[app.spin]),
_ => String::new(),
};
let mode = if app.focus == Focus::Pane {
"‹pane›"
} else {
"‹nav›"
};
let mut spans = vec![
Span::styled(
"🔮 seer",
Style::default()
.fg(theme.mauve)
.add_modifier(Modifier::BOLD),
),
Span::styled(" │ ", Style::default().fg(theme.surface1)),
Span::styled("target ", Style::default().fg(theme.overlay)),
Span::styled(target, Style::default().fg(theme.text)),
Span::styled(format!(" {ip} "), Style::default().fg(theme.teal)),
];
if app.format != seer_core::output::OutputFormat::Human {
spans.push(Span::styled(
format!(" --format {:?} ", app.format).to_lowercase(),
Style::default().fg(theme.green),
));
}
spans.push(Span::styled(
format!(" {mode} "),
Style::default().fg(theme.lavender),
));
spans.push(Span::styled(
format!("│ v{}", env!("CARGO_PKG_VERSION")),
Style::default().fg(theme.overlay0),
));
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme.mantle)),
area,
);
}
fn strip_groups(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let active = app.current_lens().group;
let mut spans = vec![Span::raw(" ")];
for group in lenses::groups() {
let style = if group == active {
Style::default()
.fg(theme.base)
.bg(theme.mauve)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.subtext)
};
spans.push(Span::styled(format!(" {group} "), style));
spans.push(Span::raw(" "));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme.mantle)),
area,
);
}
fn strip_lenses(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let all = lenses::lenses();
let members = lenses::group_members(app.current_lens().group);
let items: Vec<Span> = members
.iter()
.enumerate()
.map(|(n, &i)| {
let l = &all[i];
let text = format!(" {} {} {} ", n + 1, l.glyph, l.label);
let style = if i == app.lens {
Style::default()
.fg(theme.text)
.bg(theme.surface0)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.subtext)
};
Span::styled(text, style)
})
.collect();
let active = members.iter().position(|&i| i == app.lens).unwrap_or(0);
let width = usize::from(area.width);
let span_w = |range: &[Span]| range.iter().map(Span::width).sum::<usize>();
const MARK: usize = 2;
let mut start = 0;
while start < active && MARK + span_w(&items[start..=active]) + MARK > width {
start += 1;
}
let mut end = start;
while end < items.len() {
let more = end + 1 < items.len();
let reserve = if more { MARK } else { 0 };
if MARK + span_w(&items[start..=end]) + reserve > width && end > active {
break;
}
end += 1;
}
let mut spans = vec![Span::styled(
if start > 0 { "‹ " } else { " " },
Style::default().fg(theme.overlay0),
)];
spans.extend(items[start..end].iter().cloned());
if end < items.len() {
spans.push(Span::styled(" ›", Style::default().fg(theme.overlay0)));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme.mantle)),
area,
);
}
fn main_pane(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let lens = app.current_lens();
let content = if !lens.tabs.is_empty() && app.format == seer_core::output::OutputFormat::Human {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
sub_tabs(f, chunks[0], app, theme);
chunks[1]
} else {
area
};
let pane_page = match lens.key {
LensKey::Follow => Some(lenses::follow::page(theme, &app.panes.follow, app.spin)),
LensKey::Diff => {
let domain_a = app.panes.diff.effective_a(app.domain.as_deref());
Some(lenses::diff::page(
theme,
domain_a.as_deref(),
&app.panes.diff.b,
field_buf(&app.input_mode, EditTarget::DiffB),
app.focus == Focus::Pane,
app.state_of(app.lens),
))
}
LensKey::Bulk => Some(lenses::bulk::page(
theme,
&app.panes.bulk,
field_buf(&app.input_mode, EditTarget::BulkDomains),
app.spin,
)),
LensKey::Tld => {
let (loaded, loading) = match app.state_of(app.lens) {
LensState::Loaded(LensData::Tld(t)) => (Some(t.as_ref()), false),
LensState::Loading => (None, true),
_ => (None, false),
};
Some(lenses::tld::page(
theme,
&app.panes.tld,
loaded,
loading,
field_buf(&app.input_mode, EditTarget::TldFilter),
))
}
_ => None,
};
if let Some(p) = pane_page {
page::draw(f, content, theme, p, app.focus == Focus::Pane);
return;
}
match app.state_of(app.lens) {
LensState::Loading => {
let target = app
.pending_target(app.lens)
.or_else(|| app.domain.clone())
.unwrap_or_default();
let line = Line::from(Span::styled(
format!("{} querying {target}…", SPIN[app.spin]),
Style::default().fg(theme.overlay),
));
f.render_widget(Paragraph::new(line), content);
return;
}
LensState::Error(e) => {
let inner = panel::render(f, content, theme, lens.label, theme.red, false);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
sanitize_line(e),
Style::default().fg(theme.red),
)))
.wrap(Wrap { trim: false }),
inner,
);
return;
}
LensState::Idle => {
let hint_text = match (lens.key, app.tab, app.domain.as_deref()) {
(LensKey::Rdap, 2, _) => "use :rdap AS<number> (e.g. :rdap AS15169)".to_string(),
(LensKey::Rdap, 1, _) => {
"use :rdap <ip> or navigate to a domain first".to_string()
}
(_, _, Some(domain)) if lens.heavy => format!(
"press ↵ to run {} for {domain} (active scan: CT logs + HTTP probes)",
lens.label
),
_ => "press / to look up a domain".to_string(),
};
let hint = Line::from(Span::styled(hint_text, Style::default().fg(theme.overlay0)));
f.render_widget(Paragraph::new(hint), content);
return;
}
LensState::Loaded(_) => {}
}
if app.format != seer_core::output::OutputFormat::Human {
if let LensState::Loaded(data) = app.state_of(app.lens) {
let text = crate::payload::serialize(data, app.format);
let lines: Vec<Line> = text.lines().map(|l| Line::from(sanitize_line(l))).collect();
let total = lines.len();
let raw_title = format!("{} · raw", lens.label);
let inner = panel::render(
f,
content,
theme,
&raw_title,
theme.green,
app.focus == Focus::Pane,
);
let max = total.saturating_sub(usize::from(inner.height));
let top = usize::from(app.raw_scroll).min(max);
let top = u16::try_from(top).unwrap_or(u16::MAX);
f.render_widget(
Paragraph::new(lines)
.style(Style::default().fg(theme.subtext))
.scroll((top, 0)),
inner,
);
}
return;
}
if let LensState::Loaded(data) = app.state_of(app.lens) {
let focused = app.focus == Focus::Pane;
let filter = app.active_filter();
let filtered = crate::tui::filter::apply(data, &filter);
let render_data = filtered.as_ref().unwrap_or(data);
lenses::render(
f,
content,
theme,
lens.key,
app.tab,
render_data,
&filter,
focused,
app.sel,
&app.panes,
);
}
}
fn sub_tabs(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let lens = app.current_lens();
let mut spans = Vec::new();
for (i, t) in lens.tabs.iter().enumerate() {
let on = i == app.tab;
spans.push(Span::styled(
format!(" {t} "),
Style::default().fg(if on { theme.mauve } else { theme.subtext }),
));
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
"[ ] switch tab",
Style::default().fg(theme.overlay0),
));
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn status_or_command(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
if let InputMode::Field {
target: EditTarget::LensFilter,
buf,
} = &app.input_mode
{
let matches = app.row_count();
let line = Line::from(vec![
Span::styled(
"filter> ",
Style::default()
.fg(theme.mauve)
.add_modifier(Modifier::BOLD),
),
Span::styled(buf.with_caret("█"), Style::default().fg(theme.text)),
Span::styled(
format!(" {matches} match(es) [enter apply · esc cancel]"),
Style::default().fg(theme.overlay0),
),
]);
f.render_widget(
Paragraph::new(line).style(Style::default().bg(theme.mantle)),
area,
);
return;
}
if let InputMode::Command(buf) = &app.input_mode {
let line = Line::from(vec![
Span::styled(
"seer> ",
Style::default()
.fg(theme.mauve)
.add_modifier(Modifier::BOLD),
),
Span::styled(buf.with_caret("█"), Style::default().fg(theme.text)),
]);
f.render_widget(
Paragraph::new(line).style(Style::default().bg(theme.mantle)),
area,
);
return;
}
if let InputMode::Field { target, buf } = &app.input_mode {
if let Some(prompt) = status_prompt(app, *target) {
let line = Line::from(vec![
Span::styled(
prompt,
Style::default()
.fg(theme.mauve)
.add_modifier(Modifier::BOLD),
),
Span::styled(buf.with_caret("█"), Style::default().fg(theme.text)),
Span::styled(
" [enter apply · esc cancel]",
Style::default().fg(theme.overlay0),
),
]);
f.render_widget(
Paragraph::new(line).style(Style::default().bg(theme.mantle)),
area,
);
return;
}
}
let lens = app.current_lens();
let mut spans = vec![Span::styled(
format!("{} {} ", lens.glyph, lens.label),
Style::default().fg(theme.lavender),
)];
if let Some(t) = &app.toast {
spans.push(Span::styled(
format!("● {}", sanitize_line(&t.msg)),
Style::default().fg(theme.tone(&t.tone)),
));
}
let active_filter = app.active_filter();
if !active_filter.is_empty() {
spans.push(Span::styled(
format!("filter:/{}/ ", active_filter),
Style::default().fg(theme.mauve),
));
}
spans.push(Span::raw(" "));
let lens_keys: Vec<(String, &str)> = lens.keys.iter().map(|h| (h.label(), h.what)).collect();
let keys: Vec<(String, &str)> = if app.focus == Focus::Pane && !lens_keys.is_empty() {
let mut keys = lens_keys;
if lens.filterable() {
keys.push(("/".to_string(), "filter"));
}
keys.extend([("h".to_string(), "back"), ("?".to_string(), "help")]);
keys
} else {
let tabs = (!lens.tabs.is_empty()).then_some(("[ ]", "tab"));
[("j/k", "move"), ("␣", "go to"), ("tab", "focus")]
.into_iter()
.chain(tabs)
.chain([
("r", "raw"),
("y", "copy"),
("/", "lookup"),
(":", "cmd"),
("?", "help"),
])
.map(|(k, t)| (k.to_string(), t))
.collect()
};
for (k, t) in keys {
spans.push(Span::styled(
format!(" {k}"),
Style::default().fg(theme.crust).bg(theme.overlay),
));
spans.push(Span::styled(
format!(" {t} "),
Style::default().fg(theme.overlay0),
));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme.mantle)),
area,
);
}
fn switcher(f: &mut Frame, area: Rect, buf: &LineEditor, theme: &Theme) {
let all = lenses::lenses();
let matches = lenses::switch_matches(buf.as_str());
let mut lines = vec![Line::from(vec![
Span::styled("> ", Style::default().fg(theme.mauve)),
Span::styled(buf.with_caret("▏"), Style::default().fg(theme.text)),
])];
if matches.is_empty() {
lines.push(Line::from(Span::styled(
"no lens matches",
Style::default().fg(theme.overlay0),
)));
}
for (n, &i) in matches.iter().enumerate() {
let l = &all[i];
lines.push(
Line::from(vec![
Span::styled(format!("{} ", l.glyph), Style::default().fg(theme.lavender)),
Span::raw(format!("{:<14}", l.label)),
Span::styled(l.group, Style::default().fg(theme.overlay0)),
])
.style(crate::tui::widgets::row_style(theme, n == 0)),
);
}
let w = 40u16.min(area.width.saturating_sub(4));
let h = u16::try_from(lines.len() + 2)
.unwrap_or(u16::MAX)
.min(area.height.saturating_sub(2));
let popup = Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 3,
width: w,
height: h,
};
f.render_widget(Clear, popup);
f.render_widget(
Block::default().style(Style::default().bg(theme.base).fg(theme.text)),
popup,
);
let inner = panel::render(f, popup, theme, "go to", theme.mauve, true);
f.render_widget(Paragraph::new(lines), inner);
}
const HELP_KEY_COL: usize = 16;
fn help_overlay(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let global = [
("j / k · ↑ ↓", "move selection (nav or pane)"),
("1 … 9", "lens in the current group"),
("{ / }", "previous / next group"),
("Space", "go to any lens by name"),
("Tab", "toggle focus: nav ⇄ pane"),
("[ / ]", "switch sub-tabs (RDAP)"),
("↵ / l", "enter the pane · run Subdomains / Takeover"),
("h / Esc", "back to nav"),
("g / G", "jump to top / bottom"),
("r", "raw output ⇄ human view"),
("PgUp / PgDn", "scroll the raw view (j / k once focused)"),
("y", "copy current output to clipboard"),
("/", "look up a domain"),
(":", "command (lookup · dig · ssl · set output · theme · q)"),
("?", "this help · Esc closes"),
];
let lens = app.current_lens();
let lens_rows: Vec<(String, &str)> = lens.keys.iter().map(|h| (h.label(), h.what)).collect();
let row = |(k, t): (&str, &str)| {
Line::from(vec![
Span::styled(
format!("{k:<HELP_KEY_COL$}"),
Style::default().fg(theme.peach),
),
Span::styled(t.to_string(), Style::default().fg(theme.subtext)),
])
};
let mut lines: Vec<Line> = Vec::new();
if !lens_rows.is_empty() {
lines.push(Line::from(Span::styled(
format!("{} {} keys", lens.glyph, lens.label),
Style::default().fg(theme.lavender),
)));
lines.extend(lens_rows.iter().map(|(k, t)| row((k.as_str(), *t))));
lines.push(Line::default());
}
lines.extend(global.iter().copied().map(row));
let longest = lines.iter().map(Line::width).max().unwrap_or(0);
let w = u16::try_from(longest + 4)
.unwrap_or(u16::MAX)
.max(60)
.min(area.width.saturating_sub(4));
let rows = u16::try_from(lines.len() + 4).unwrap_or(u16::MAX);
let h = rows.min(area.height.saturating_sub(2));
let popup = Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
};
f.render_widget(Clear, popup);
f.render_widget(
Block::default().style(Style::default().bg(theme.base).fg(theme.text)),
popup,
);
let inner = panel::render(f, popup, theme, "keybindings", theme.lavender, true);
inspector::draw(f, inner, theme, lines);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::app::App;
use crate::tui::test_util::{render_buffer, render_lines, render_text};
fn full_buf(app: &App, theme: &Theme) -> String {
render_text(100, 30, |f| view(f, app, theme))
}
#[test]
fn strip_shows_groups_and_the_active_groups_lenses() {
let mut app = App::new(None);
app.lens = LensKey::Dnssec.index();
let s = render_lines(140, 30, |f| view(f, &app, app.theme()));
let rows: Vec<&str> = s.lines().collect();
assert!(
rows[1].contains("LOOKUP") && rows[1].contains("POWER"),
"{s}"
);
assert!(
rows[2].contains("1 ≣ Records") && rows[2].contains("2 ◑ DNSSEC"),
"{s}"
);
assert!(!rows[2].contains("WHOIS"), "{s}");
assert!(rows[3].starts_with("press /"), "{s}");
}
#[test]
fn a_narrow_strip_keeps_the_active_lens_visible() {
let mut app = App::new(None);
app.lens = LensKey::Follow.index();
let s = render_lines(40, 20, |f| view(f, &app, app.theme()));
let row = s.lines().nth(2).unwrap_or_default();
assert!(row.contains("Follow") && row.starts_with('‹'), "{s}");
}
#[test]
fn switcher_popup_lists_matches() {
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
let mut app = App::new(None);
for c in [' ', 's', 'u', 'b'] {
app.update(crate::tui::action::Msg::Input(Event::Key(KeyEvent::new(
KeyCode::Char(c),
KeyModifiers::NONE,
))));
}
let s = render_lines(100, 30, |f| view(f, &app, app.theme()));
assert!(s.contains("go to"), "{s}");
assert!(s.contains("Subdomains") && s.contains("SECURITY"), "{s}");
}
#[test]
fn the_status_bar_fits_its_help_hint_at_80_columns() {
let mut app = App::new(None);
app.lens = LensKey::Audit.index();
let s = render_lines(80, 24, |f| view(f, &app, app.theme()));
let status = s.lines().last().unwrap_or_default();
assert!(status.contains("? help"), "{status}");
assert!(!status.contains("[ ] tab"), "{status}");
app.lens = LensKey::Rdap.index();
let s = render_lines(140, 24, |f| view(f, &app, app.theme()));
assert!(
s.lines().last().is_some_and(|l| l.contains("[ ] tab")),
"{s}"
);
}
#[test]
fn help_overlay_rows_are_not_clipped() {
let mut app = App::new(None);
app.help = true;
let s = render_lines(100, 30, |f| view(f, &app, app.theme()));
assert!(s.contains("theme · q)"), "the : row must render whole: {s}");
}
#[test]
fn help_overlay_keeps_every_lens_key_on_a_small_terminal() {
let mut app = App::new(None);
app.lens = LensKey::Bulk.index();
app.help = true;
let s = render_lines(80, 24, |f| view(f, &app, app.theme()));
assert!(s.contains(&format!("{:<16}export", "e")), "{s}");
assert!(
s.contains("more lines"),
"the cut global rows are marked: {s}"
);
}
#[test]
fn help_overlay_lists_the_current_lens_keys() {
let mut app = App::new(None);
app.lens = LensKey::Follow.index();
app.help = true;
let s = render_lines(100, 40, |f| view(f, &app, app.theme()));
assert!(s.contains("Follow keys"), "{s}");
assert!(s.contains(&format!("{:<16}interval", "i")), "{s}");
}
#[test]
fn status_bar_shows_lens_keys_when_the_pane_is_focused() {
let mut app = App::new(None);
app.lens = LensKey::Follow.index();
let status = |app: &App| {
render_lines(140, 30, |f| view(f, app, app.theme()))
.lines()
.last()
.unwrap_or_default()
.to_string()
};
assert!(!status(&app).contains("interval"), "{}", status(&app));
app.focus = Focus::Pane;
assert!(status(&app).contains("interval"), "{}", status(&app));
}
#[test]
fn follow_lens_renders_its_pane_not_the_generic_hint() {
let theme = Theme::frappe();
let mut app = App::new(None);
app.lens = lenses::find_by_cmd_or_key("follow").unwrap();
let s = full_buf(&app, &theme);
assert!(s.contains("s start"), "Follow pane (hints) should render");
assert!(
!s.contains("press / to look up a domain"),
"Follow must not fall back to the generic idle hint"
);
}
#[test]
fn fields_without_an_inline_editor_are_echoed_in_the_status_bar() {
let theme = Theme::frappe();
let mut app = App::new(None);
app.lens = lenses::find_by_cmd_or_key("follow").unwrap();
let mut buf: LineEditor = "60".into();
buf.left();
app.input_mode = InputMode::Field {
target: EditTarget::FollowInterval,
buf,
};
let s = full_buf(&app, &theme);
assert!(
s.contains("interval seconds (now 30)> 6█0"),
"typed interval (with caret at the cursor) must be visible"
);
for (target, prompt) in [
(EditTarget::FollowCount, "check count (now 20)> "),
(EditTarget::BulkPath, "domains file> "),
(EditTarget::WatchAdd, "watch add> "),
] {
app.input_mode = InputMode::Field {
target,
buf: "abc".into(),
};
let s = full_buf(&app, &theme);
assert!(s.contains(&format!("{prompt}abc█")), "{target:?}");
}
}
#[test]
fn lens_errors_are_sanitized_and_wrapped() {
let theme = Theme::frappe();
let mut app = App::new(None);
app.update(crate::tui::action::Msg::Data {
lens: LensKey::Overview,
gen: 0,
result: Err(format!(
"whois.example.test said \u{1b}[2J{} END",
"x ".repeat(60)
)),
});
let s = render_text(100, 30, |f| view(f, &app, &theme));
assert!(!s.contains('\u{1b}'), "{s:?}");
assert!(s.contains("END"), "the tail wraps into view: {s}");
}
#[test]
fn a_heavy_lens_waits_for_enter() {
let theme = Theme::frappe();
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
app.lens = LensKey::Takeover.index();
let s = full_buf(&app, &theme);
assert!(s.contains("press ↵ to run Takeover for example.com"), "{s}");
}
#[test]
fn the_raw_view_scrolls() {
use crate::payload::fixtures;
use seer_core::RecordType;
let theme = Theme::frappe();
let mut app = App::new(None);
app.lens = LensKey::Dns.index();
app.update(crate::tui::action::Msg::Data {
lens: LensKey::Dns,
gen: 0,
result: Ok(LensData::Dig(Box::new(fixtures::dig(
RecordType::A,
vec![fixtures::a("www.seer.test", "192.0.2.7")],
)))),
});
app.format = seer_core::output::OutputFormat::Json;
let top = render_text(100, 14, |f| view(f, &app, &theme));
assert!(top.contains("\"status\": \"NOERROR\""), "{top}");
app.raw_scroll = 6;
let scrolled = render_text(100, 14, |f| view(f, &app, &theme));
assert!(!scrolled.contains("\"status\": \"NOERROR\""), "{scrolled}");
}
#[test]
fn loading_indicator_names_the_requested_target() {
use crate::tui::action::Msg;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
let theme = Theme::frappe();
let mut app = App::new(Some("example.com".into()));
let _ = app.take_startup_actions();
let press = |app: &mut App, code: KeyCode| {
let _ = app.update(Msg::Input(Event::Key(KeyEvent::new(
code,
KeyModifiers::NONE,
))));
};
press(&mut app, KeyCode::Char(':'));
for c in "rdap 8.8.8.8".chars() {
press(&mut app, KeyCode::Char(c));
}
press(&mut app, KeyCode::Enter);
let s = full_buf(&app, &theme);
assert!(s.contains("querying 8.8.8.8"), "not the session domain");
}
#[test]
fn raw_view_shows_the_dig_result_object() {
use crate::payload::fixtures;
use seer_core::RecordType;
let theme = Theme::frappe();
let mut app = App::new(None);
app.lens = lenses::find_by_cmd_or_key("dns").unwrap();
app.update(crate::tui::action::Msg::Data {
lens: LensKey::Dns,
gen: 0,
result: Ok(LensData::Dig(Box::new(fixtures::dig(
RecordType::A,
vec![fixtures::a("www.seer.test", "192.0.2.7")],
)))),
});
let human = full_buf(&app, &theme);
assert!(human.contains("● NOERROR"), "{human}");
app.format = seer_core::output::OutputFormat::Json;
let raw = full_buf(&app, &theme);
assert!(raw.contains("Records · raw"), "{raw}");
assert!(raw.contains("\"status\": \"NOERROR\""), "{raw}");
assert!(raw.contains("\"address\": \"192.0.2.7\""), "{raw}");
}
#[test]
fn shell_renders_without_panicking() {
let theme = Theme::frappe();
let app = App::new(None);
let s = full_buf(&app, &theme);
assert!(s.contains("seer"), "top-bar brand missing");
assert!(s.contains("Overview"), "first lens label missing");
assert!(s.contains("LOOKUP"), "group header missing");
}
#[test]
fn frame_canvas_is_painted_with_theme_base() {
let mut app = App::new(None);
assert!(app.set_theme_by_name("latte"));
let buffer = render_buffer(100, 30, |f| view(f, &app, app.theme()));
let latte = Theme::latte();
for (x, y) in [(60u16, 15u16), (2, 20)] {
assert_eq!(buffer[(x, y)].bg, latte.base, "cell ({x},{y})");
}
}
#[test]
fn help_overlay_keeps_theme_base_background() {
let mut app = App::new(None);
assert!(app.set_theme_by_name("latte"));
app.help = true;
let buffer = render_buffer(100, 30, |f| view(f, &app, app.theme()));
assert_eq!(buffer[(50, 15)].bg, Theme::latte().base);
}
#[test]
fn live_theme_swap_recolors_the_frame() {
let top_bar_bg =
|app: &App| render_buffer(100, 30, |f| view(f, app, app.theme()))[(0, 0)].bg;
let mut app = App::new(None);
assert_eq!(top_bar_bg(&app), Theme::frappe().mantle);
assert!(app.set_theme_by_name("latte"));
assert_eq!(top_bar_bg(&app), Theme::latte().mantle);
assert_ne!(Theme::frappe().mantle, Theme::latte().mantle);
}
}