use chrono::{DateTime, Local};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Position, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Clear, Paragraph, Row, Table},
};
use crate::{
app::{App, LISTS, List, Tab, View},
types::{HistoryEntry, IpInfo, WorkerInfo},
};
const WHITE: Style = Style::new().fg(Color::White);
const BOLD: Style = WHITE.add_modifier(Modifier::BOLD);
const DIM: Style = Style::new().fg(Color::DarkGray);
const GRAY: Style = Style::new().fg(Color::Gray);
const GREEN: Style = Style::new().fg(Color::Green);
const LIGHT_GREEN: Style = Style::new().fg(Color::LightGreen);
const ACTIVE_PILL: Style = Style::new()
.fg(Color::Black)
.bg(Color::White)
.add_modifier(Modifier::BOLD);
const SELECTED_ROW: Style = Style::new()
.bg(Color::Rgb(35, 35, 40))
.add_modifier(Modifier::BOLD);
const SEPARATOR: &str = " • ";
const YELLOW: Style = Style::new().fg(Color::Yellow);
const STALE_AFTER_MS: u64 = 30_000;
const NARROW_SEPARATOR: &str = " • ";
const FOOTER_HINTS: [(&str, &str); 7] = [
("tab", "switch"),
("j/k", "move"),
("/", "search"),
("e", "auth"),
("x", "clear"),
("r", "refresh"),
("q", "quit"),
];
const NARROW_FOOTER_HINTS: [(&str, &str); 3] = [("tab", "switch"), ("e", "auth"), ("q", "quit")];
const TABLE_CHROME_ROWS: u16 = 3;
struct Column<T> {
min_width: u16,
title: &'static str,
width: Constraint,
cell: fn(&T) -> Span<'_>,
}
type Detail<'a> = (u16, &'static str, Span<'a>);
fn fmt_bytes(b: u64) -> String {
match b {
0..1024 => format!("{b} B"),
1024..1_048_576 => format!("{:.1} KB", b as f64 / 1024.0),
_ => format!("{:.2} MB", b as f64 / 1_048_576.0),
}
}
fn fmt_dur(s: u64) -> String {
let (d, h, m, sec) = (s / 86400, (s % 86400) / 3600, (s % 3600) / 60, s % 60);
if d > 0 {
format!("{d}d {h:02}h {m:02}m")
} else if h > 0 {
format!("{h}h {m:02}m {sec:02}s")
} else if m > 0 {
format!("{m}m {sec:02}s")
} else {
format!("{sec}s")
}
}
fn fmt_age(s: u64) -> String {
match s {
0..60 => format!("{s}s ago"),
60..3600 => format!("{}m ago", s / 60),
3600..86400 => format!("{}h ago", s / 3600),
_ => format!("{}d ago", s / 86400),
}
}
fn to_millis(ts: u64) -> u64 {
if ts < 10_000_000_000 {
ts.saturating_mul(1000)
} else {
ts
}
}
fn fmt_ts(ts: u64) -> String {
i64::try_from(to_millis(ts))
.ok()
.filter(|_| ts != 0)
.and_then(DateTime::from_timestamp_millis)
.map_or_else(
|| "-".to_string(),
|dt| {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
},
)
}
fn or_dash(value: Option<&str>) -> &str {
value.unwrap_or("-")
}
fn panel<'a>(title: impl Into<Line<'a>>, border: Style) -> Block<'a> {
Block::bordered()
.border_type(BorderType::Rounded)
.border_style(border)
.title(title)
}
fn key_hints<'a>(hints: &[(&'a str, &'a str)], separator: &str) -> Vec<Span<'a>> {
let last = hints.len().saturating_sub(1);
let mut spans = Vec::with_capacity(hints.len() * 2);
for (i, &(key, label)) in hints.iter().enumerate() {
let tail = if i == last { "" } else { separator };
spans.push(Span::styled(key, WHITE));
spans.push(Span::styled(format!(" {label}{tail}"), DIM));
}
spans
}
fn hint_at<'a>(hints: &[(&'a str, &'a str)], separator: &str, x: u16) -> Option<&'a str> {
let mut start = 0;
for &(key, label) in hints {
let end = start + key.chars().count() + label.chars().count() + 1;
if (start..end).contains(&usize::from(x)) {
return Some(key);
}
start = end + separator.chars().count();
}
None
}
fn hints_width(hints: &[(&str, &str)], separator: &str) -> u16 {
let text: usize = hints
.iter()
.map(|(k, l)| k.chars().count() + l.chars().count() + 1)
.sum();
let gaps = separator.chars().count() * hints.len().saturating_sub(1);
u16::try_from(text + gaps).unwrap_or(u16::MAX)
}
fn badges<'a>(items: &[(&'a str, Color, &'a str)]) -> Line<'a> {
let mut spans = Vec::with_capacity(items.len() * 2);
for &(key, color, label) in items {
spans.push(Span::styled(key, Style::new().fg(Color::Black).bg(color)));
spans.push(Span::styled(label, DIM));
}
Line::from(spans)
}
fn centered(area: Rect, width: u16, height: u16, margin: u16) -> Rect {
let width = width.min(area.width.saturating_sub(margin));
let height = height.min(area.height.saturating_sub(margin));
Rect::new(
(area.width - width) / 2,
(area.height - height) / 2,
width,
height,
)
}
pub fn auth_modal_area(area: Rect) -> Rect {
centered(area, 46, 7, 2)
}
fn auth_hints(app: &App) -> [(&'static str, &'static str); 3] {
let toggle = if app.mask_credentials { "show" } else { "hide" };
[("enter", "save"), ("tab", toggle), ("esc", "cancel")]
}
fn auth_hint_row(modal: Rect) -> Rect {
Rect::new(modal.x + 1, modal.y + 4, modal.width.saturating_sub(2), 1)
}
pub fn auth_hint_at(app: &App, screen: Rect, pos: Position) -> Option<&'static str> {
let row = auth_hint_row(auth_modal_area(screen));
let hints = auth_hints(app);
let start = row.x + row.width.saturating_sub(hints_width(&hints, SEPARATOR)) / 2;
(pos.y == row.y)
.then(|| hint_at(&hints, SEPARATOR, pos.x.checked_sub(start)?))
.flatten()
}
fn footer_hints(width: u16) -> (&'static [(&'static str, &'static str)], &'static str) {
if width < 60 {
(&NARROW_FOOTER_HINTS, NARROW_SEPARATOR)
} else {
(&FOOTER_HINTS, SEPARATOR)
}
}
pub fn footer_key_at(app: &App, screen: Rect, x: u16) -> Option<&'static str> {
if app.auth_mode || app.search_mode {
return None;
}
let (hints, separator) = footer_hints(screen.width);
hint_at(hints, separator, x)
}
pub fn tab_at(app: &App, x: u16) -> Option<Tab> {
let mut start = 0;
for (tab, title) in tab_titles(app) {
let end = start + title.chars().count();
if (start..end).contains(&usize::from(x)) {
return Some(tab);
}
start = end + 1;
}
None
}
pub fn list_row_at(app: &App, pos: Position) -> Option<(List, usize)> {
LISTS.into_iter().find_map(|list| {
let view = app.view(list).get();
let row = pos.y.checked_sub(view.area.y + TABLE_CHROME_ROWS)?;
(view.area.contains(pos) && row < view.area.height.saturating_sub(TABLE_CHROME_ROWS + 1))
.then_some((list, view.offset + usize::from(row)))
})
}
pub fn render_ui(app: &App, frame: &mut Frame) {
let area = frame.area();
if area.width < 25 || area.height < 6 {
let msg = format!("Resize window ({}x{})", area.width, area.height);
frame.render_widget(Paragraph::new(msg).style(WHITE), area);
return;
}
for list in LISTS {
let offset = app.view(list).get().offset;
app.view(list).set(View {
area: Rect::default(),
offset,
});
}
let compact = area.height < 18;
let [header, body, footer] = Layout::vertical([
Constraint::Length(if compact { 1 } else { 2 }),
Constraint::Min(4),
Constraint::Length(1),
])
.areas(area);
render_header(app, frame, header, compact);
match app.tab {
Tab::Workers => render_workers(app, frame, body),
Tab::Queue => render_jobs(app, frame, body, List::Queue),
Tab::History => render_jobs(app, frame, body, List::History),
Tab::Search => render_search(app, frame, body),
}
render_footer(app, frame, footer);
if app.auth_mode {
render_auth_modal(app, frame, area);
} else if app.inspector_mode {
render_inspector_modal(app, frame, area);
}
}
fn tab_title(name: &str, has_data: bool, count: usize) -> String {
if has_data {
format!(" {name} ({count}) ")
} else {
format!(" {name} ")
}
}
fn tab_titles(app: &App) -> [(Tab, String); 4] {
let stats = app.stats.as_ref();
[
(
Tab::Workers,
tab_title(
"Workers",
stats.is_some() || !app.workers.is_empty(),
stats
.and_then(|s| s.active_printers)
.unwrap_or(app.workers.len()),
),
),
(
Tab::Queue,
tab_title(
"Queue",
stats.is_some() || !app.queue.is_empty(),
stats
.and_then(|s| s.queued_count)
.unwrap_or(app.queue.len()),
),
),
(
Tab::History,
tab_title(
"History",
stats.is_some() || !app.history.is_empty(),
stats
.and_then(|s| s.history_count)
.unwrap_or(app.history.len()),
),
),
(Tab::Search, " Search ".to_string()),
]
}
fn render_header(app: &App, frame: &mut Frame, area: Rect, compact: bool) {
let stats = app.stats.as_ref();
let tabs = tab_titles(app);
let mut left = Vec::new();
for (i, (tab, title)) in tabs.into_iter().enumerate() {
if i > 0 {
left.push(Span::raw(" "));
}
left.push(Span::styled(
title,
if tab == app.tab { ACTIVE_PILL } else { DIM },
));
}
left.push(Span::raw(" "));
if app.search_mode || !app.search_query.is_empty() {
left.push(Span::styled("❯ ", BOLD));
left.push(Span::styled(app.search_query.as_str(), WHITE));
if app.search_mode {
let cursor = if app.cursor_visible() { "█" } else { " " };
left.push(Span::styled(cursor, WHITE));
}
}
let right = match stats {
_ if app.is_unauthorized => vec![Span::styled("unauthorized", DIM)],
None => vec![
Span::styled(format!("{} ", app.spinner()), WHITE),
Span::styled("connecting", DIM),
],
Some(stats) => {
let online = stats
.status
.as_deref()
.is_some_and(|s| s.eq_ignore_ascii_case("online"));
let mut spans = vec![if online {
Span::styled("online", GREEN)
} else {
Span::styled("offline", DIM)
}];
if let Some(latency) = app.latency_ms
&& area.width >= 75
{
spans.push(Span::styled(format!("{SEPARATOR}{latency:>4}ms"), DIM));
}
if area.width >= 90 {
let uptime = app.realtime_uptime().map_or_else(|| "-".into(), fmt_dur);
spans.push(Span::styled(format!("{SEPARATOR}{uptime}"), DIM));
}
spans
}
};
let [left_area, right_area] =
Layout::horizontal([Constraint::Percentage(55), Constraint::Percentage(45)]).areas(area);
frame.render_widget(Paragraph::new(Line::from(left)), left_area);
frame.render_widget(
Paragraph::new(Line::from(right)).alignment(Alignment::Right),
right_area,
);
if !compact && area.height >= 2 {
let rule = Paragraph::new("─".repeat(usize::from(area.width))).style(DIM);
frame.render_widget(rule, Rect::new(area.x, area.y + 1, area.width, 1));
}
}
fn status_span(app: &App) -> Span<'_> {
if let Some(toast) = app.active_toast() {
Span::styled(
format!(" ✓ {toast} "),
Style::new()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD),
)
} else if app.is_loading {
Span::styled(format!("{} {}", app.spinner(), app.status_msg), WHITE)
} else {
Span::styled(app.status_msg.as_str(), DIM)
}
}
fn render_footer(app: &App, frame: &mut Frame, area: Rect) {
let line = if app.auth_mode {
badges(&[
(" tab ", Color::White, " next "),
(" enter ", Color::Green, " save "),
(" esc ", Color::DarkGray, " cancel"),
])
} else if app.search_mode {
badges(&[
(" esc ", Color::White, " exit "),
(" enter ", Color::White, " confirm"),
])
} else {
let (hints, separator) = footer_hints(area.width);
let mut spans = key_hints(hints, separator);
if area.width >= 60 {
spans.push(Span::raw(" "));
spans.push(status_span(app));
}
Line::from(spans)
};
frame.render_widget(Paragraph::new(line), area);
}
fn render_auth_modal(app: &App, frame: &mut Frame, area: Rect) {
let modal = auth_modal_area(area);
frame.render_widget(Clear, modal);
frame.render_widget(panel(" Password ", WHITE), modal);
let input_area = Rect::new(modal.x + 1, modal.y + 1, modal.width.saturating_sub(2), 3);
let value = if app.mask_credentials {
"•".repeat(app.input_password.len())
} else {
app.input_password.clone()
};
let field = if value.is_empty() {
Span::styled("enter password...", DIM)
} else {
Span::styled(value, WHITE)
};
let input = Paragraph::new(Line::from(vec![
Span::styled("› ", WHITE),
field,
Span::styled("█", WHITE),
]))
.block(panel(" Password ", WHITE));
frame.render_widget(input, input_area);
let hint = key_hints(&auth_hints(app), SEPARATOR);
frame.render_widget(
Paragraph::new(Line::from(hint)).alignment(Alignment::Center),
auth_hint_row(modal),
);
}
fn empty_message<'a>(app: &App, loading: &'a str, no_match: &'a str, empty: &'a str) -> &'a str {
if app.is_unauthorized {
"401 Unauthorized"
} else if app.is_loading {
loading
} else if !app.search_query.is_empty() {
no_match
} else {
empty
}
}
fn render_empty(frame: &mut Frame, area: Rect, title: &str, message: &str) {
let block = panel(title, DIM);
let inner = block.inner(area);
frame.render_widget(block, area);
let line = Rect::new(
inner.x,
inner.y + inner.height.saturating_sub(1) / 2,
inner.width,
1,
);
frame.render_widget(
Paragraph::new(Span::styled(message, BOLD)).alignment(Alignment::Center),
line,
);
}
fn scrolled_offset(previous: usize, selected: usize, rows: usize, len: usize) -> usize {
let offset = previous.min(len.saturating_sub(rows));
if selected < offset {
selected
} else if rows > 0 && selected >= offset + rows {
selected + 1 - rows
} else {
offset
}
}
#[allow(clippy::too_many_arguments)]
fn render_table<T>(
app: &App,
frame: &mut Frame,
area: Rect,
title: &str,
list: List,
items: &[&T],
columns: &[Column<T>],
details: for<'a> fn(&'a App, &'a T) -> Vec<Detail<'a>>,
) {
let selected = app.selected[list as usize];
let visible: Vec<&Column<T>> = columns
.iter()
.filter(|c| area.width >= c.min_width)
.collect();
let show_details = area.height >= 14;
let [table_area, detail_area] = Layout::vertical([
Constraint::Min(4),
Constraint::Length(if show_details { 3 } else { 0 }),
])
.areas(area);
let rows = usize::from(table_area.height.saturating_sub(TABLE_CHROME_ROWS + 1));
let offset = scrolled_offset(app.view(list).get().offset, selected, rows, items.len());
app.view(list).set(View {
area: table_area,
offset,
});
let header = Row::new(
std::iter::once("")
.chain(visible.iter().map(|c| c.title))
.map(|title| Span::styled(title, BOLD)),
)
.bottom_margin(1);
let widths = std::iter::once(Constraint::Length(2)).chain(visible.iter().map(|c| c.width));
let window = items.iter().copied().enumerate().skip(offset).take(rows);
let table_rows = window.map(|(index, item)| {
let is_selected = index == selected;
let indicator = if is_selected {
Span::styled("❯", WHITE)
} else {
Span::styled(" ", DIM)
};
Row::new(std::iter::once(indicator).chain(visible.iter().map(|c| (c.cell)(item)))).style(
if is_selected {
SELECTED_ROW
} else {
Style::new()
},
)
});
frame.render_widget(
Table::new(table_rows, widths)
.header(header)
.block(panel(title, DIM)),
table_area,
);
if let Some(item) = items.get(selected).filter(|_| show_details) {
let mut spans = Vec::new();
for (min_width, label, value) in details(app, item) {
if area.width >= min_width {
let lead = if spans.is_empty() { "" } else { SEPARATOR };
spans.push(Span::styled(format!("{lead}{label}: "), DIM));
spans.push(value);
}
}
frame.render_widget(
Paragraph::new(Line::from(spans)).block(panel(" Selected ", DIM)),
detail_area,
);
}
}
fn now_millis() -> u64 {
u64::try_from(Local::now().timestamp_millis()).unwrap_or(0)
}
fn worker_uptime(worker: &WorkerInfo) -> String {
worker.connected_at.map_or_else(
|| "-".to_string(),
|t| fmt_dur(now_millis().saturating_sub(t) / 1000),
)
}
fn worker_last_check(worker: &WorkerInfo) -> String {
worker.last_update_checked.filter(|&t| t != 0).map_or_else(
|| "never".to_string(),
|t| fmt_age(now_millis().saturating_sub(to_millis(t)) / 1000),
)
}
fn worker_status(worker: &WorkerInfo) -> Span<'static> {
let silent_for = worker
.last_seen_at
.map(|t| now_millis().saturating_sub(to_millis(t)));
if silent_for.is_some_and(|ms| ms > STALE_AFTER_MS) {
Span::styled("stale", YELLOW)
} else {
Span::styled("online", GREEN)
}
}
fn worker_columns() -> [Column<WorkerInfo>; 8] {
[
Column {
min_width: 0,
title: "IDENT",
width: Constraint::Fill(22),
cell: |w| Span::styled(or_dash(w.id.as_deref()), WHITE),
},
Column {
min_width: 55,
title: "IP",
width: Constraint::Fill(18),
cell: |w| Span::styled(or_dash(w.ip.as_deref()), WHITE),
},
Column {
min_width: 90,
title: "AGENT",
width: Constraint::Fill(22),
cell: |w| Span::styled(or_dash(w.user_agent.as_deref()), DIM),
},
Column {
min_width: 90,
title: "CHECKED",
width: Constraint::Fill(10),
cell: |w| Span::styled(worker_last_check(w), DIM),
},
Column {
min_width: 0,
title: "JOBS",
width: Constraint::Fill(10),
cell: |w| Span::styled(w.jobs_completed.unwrap_or(0).to_string(), LIGHT_GREEN),
},
Column {
min_width: 55,
title: "CONNS",
width: Constraint::Fill(8),
cell: |w| Span::styled(w.connections.unwrap_or(1).to_string(), WHITE),
},
Column {
min_width: 90,
title: "UPTIME",
width: Constraint::Fill(10),
cell: |w| Span::styled(worker_uptime(w), DIM),
},
Column {
min_width: 0,
title: "STATUS",
width: Constraint::Fill(10),
cell: worker_status,
},
]
}
fn worker_details<'a>(_app: &'a App, w: &'a WorkerInfo) -> Vec<Detail<'a>> {
vec![
(
0,
"Ident",
Span::styled(w.id.as_deref().unwrap_or("?"), BOLD),
),
(0, "Status", worker_status(w)),
(
0,
"Jobs",
Span::styled(w.jobs_completed.unwrap_or(0).to_string(), LIGHT_GREEN),
),
(
0,
"Conns",
Span::styled(w.connections.unwrap_or(1).to_string(), WHITE),
),
(
75,
"IP",
Span::styled(w.ip.as_deref().unwrap_or("?"), WHITE),
),
(
100,
"Agent",
Span::styled(w.user_agent.as_deref().unwrap_or("?"), GRAY),
),
]
}
fn history_status(h: &HistoryEntry) -> Span<'_> {
let status = or_dash(h.status.as_deref());
let style = if matches!(status, "released" | "printed") {
GREEN
} else {
DIM
};
Span::styled(status, style)
}
fn history_columns() -> [Column<HistoryEntry>; 6] {
[
Column {
min_width: 0,
title: "ID",
width: Constraint::Length(8),
cell: |h| Span::styled(h.display_id(), WHITE),
},
Column {
min_width: 0,
title: "STUDENT",
width: Constraint::Fill(24),
cell: |h| Span::styled(or_dash(h.student.as_deref()), LIGHT_GREEN),
},
Column {
min_width: 90,
title: "IP",
width: Constraint::Fill(24),
cell: |h| Span::styled(or_dash(h.client_ip.as_deref()), DIM),
},
Column {
min_width: 60,
title: "SIZE",
width: Constraint::Fill(14),
cell: |h| Span::styled(fmt_bytes(h.size_bytes.unwrap_or(0)), WHITE),
},
Column {
min_width: 0,
title: "STATUS",
width: Constraint::Fill(14),
cell: history_status,
},
Column {
min_width: 60,
title: "TIME",
width: Constraint::Fill(24),
cell: |h| Span::styled(fmt_ts(h.timestamp.unwrap_or(0)), DIM),
},
]
}
fn history_details<'a>(app: &'a App, h: &'a HistoryEntry) -> Vec<Detail<'a>> {
let student = match app.student(h.student.as_deref()) {
Some(s) => format!("{} · {} ({})", s.id, s.name, s.dept),
None => or_dash(h.student.as_deref()).to_string(),
};
vec![
(0, "ID", Span::styled(h.display_id(), WHITE)),
(0, "Student", Span::styled(student, LIGHT_GREEN)),
(
0,
"Status",
Span::styled(
or_dash(h.status.as_deref()),
GREEN.add_modifier(Modifier::BOLD),
),
),
(
70,
"Size",
Span::styled(fmt_bytes(h.size_bytes.unwrap_or(0)), WHITE),
),
(
90,
"IP",
Span::styled(or_dash(h.client_ip.as_deref()), WHITE),
),
(
115,
"Host",
Span::styled(or_dash(h.hostname.as_deref()), GRAY),
),
(
60,
"Time",
Span::styled(fmt_ts(h.timestamp.unwrap_or(0)), WHITE),
),
]
}
fn render_workers(app: &App, frame: &mut Frame, area: Rect) {
let items = app.filtered_workers();
if items.is_empty() {
let msg = empty_message(app, "Loading...", "No matches.", "No workers.");
render_empty(frame, area, " Workers ", msg);
} else {
render_table(
app,
frame,
area,
" Workers ",
List::Workers,
&items,
&worker_columns(),
worker_details,
);
}
}
fn render_jobs(app: &App, frame: &mut Frame, area: Rect, list: List) {
let (items, title, empty) = if list == List::Queue {
(app.filtered_queue(), " Queue ", "Queue is empty.")
} else {
(app.filtered_history(), " History ", "No history.")
};
if items.is_empty() {
let msg = empty_message(app, "Loading...", "No matches.", empty);
render_empty(frame, area, title, msg);
} else {
render_table(
app,
frame,
area,
title,
list,
&items,
&history_columns(),
history_details,
);
}
}
fn render_search(app: &App, frame: &mut Frame, area: Rect) {
let has_workers = !app.filtered_workers().is_empty();
let has_history = !app.filtered_history().is_empty();
match (has_workers, has_history) {
(false, false) => {
let msg = empty_message(
app,
"Searching...",
"No matches found.",
"Type query to search workers and history.",
);
render_empty(frame, area, " Search ", msg);
}
(true, true) if area.height >= 16 => {
let [top, bottom] =
Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
.areas(area);
render_workers(app, frame, top);
render_jobs(app, frame, bottom, List::History);
}
(true, false) => render_workers(app, frame, area),
_ => render_jobs(app, frame, area, List::History),
}
}
fn inspected_item(app: &App) -> (String, String) {
let index = app.selected[app.inspecting as usize];
let job = |h: &&HistoryEntry| {
(
format!(" Job: #{} ", h.display_id()),
serde_json::to_string_pretty(h).unwrap_or_default(),
)
};
match app.inspecting {
List::Workers => app.filtered_workers().get(index).map(|w| {
(
format!(" Worker: {} ", w.id.as_deref().unwrap_or("worker")),
serde_json::to_string_pretty(w).unwrap_or_default(),
)
}),
List::Queue => app.filtered_queue().get(index).map(job),
List::History => app.filtered_history().get(index).map(job),
}
.unwrap_or_else(|| (" Details ".to_string(), "{}".to_string()))
}
const INSPECTOR_HINTS: [(&str, &str); 3] =
[("esc", "close"), ("←/→", "prev/next"), ("↑/↓", "scroll")];
const SCROLL_COUNTER_WIDTH: usize = 18;
type Field = (&'static str, Option<String>);
struct InspectorLayout {
title: String,
rows: Vec<Line<'static>>,
modal: Rect,
}
impl InspectorLayout {
fn body_height(&self) -> usize {
usize::from(self.modal.height.saturating_sub(3))
}
fn max_scroll(&self) -> usize {
self.rows.len().saturating_sub(self.body_height())
}
}
fn field_rows(rows: Vec<[Field; 2]>) -> Vec<Line<'static>> {
let text = |value: Option<String>| value.unwrap_or_else(|| "-".to_string());
let left_width = rows
.iter()
.map(|[(_, value), _]| value.as_deref().map_or(1, |v| v.chars().count()))
.max()
.unwrap_or(0);
rows.into_iter()
.map(|[(left_label, left), (right_label, right)]| {
Line::from(vec![
Span::styled(format!("{left_label:<9}"), DIM),
Span::styled(format!("{:<left_width$} ", text(left)), WHITE),
Span::styled(format!("{right_label:<9}"), DIM),
Span::styled(text(right), WHITE),
])
})
.collect()
}
fn with_code(name: Option<&String>, code: Option<&String>) -> Option<String> {
match (name, code) {
(Some(name), Some(code)) => Some(format!("{name} ({code})")),
(name, _) => name.cloned(),
}
}
fn ip_info_rows(ip: &str, info: &IpInfo) -> Vec<[Field; 2]> {
let place = |lat: Option<f64>, lon: Option<f64>| Some(format!("{:.4}, {:.4}", lat?, lon?));
let timezone = match (&info.timezone.id, &info.timezone.utc) {
(Some(id), Some(utc)) => Some(format!("{id} ({utc})")),
(id, _) => id.clone(),
};
vec![
[
(
"IP",
Some(format!("{ip} {}", info.kind.as_deref().unwrap_or(""))),
),
(
"Country",
with_code(info.country.as_ref(), info.country_code.as_ref()),
),
],
[
("City", info.city.clone()),
(
"Region",
with_code(info.region.as_ref(), info.region_code.as_ref()),
),
],
[
("ISP", info.connection.isp.clone()),
("Org", info.connection.org.clone()),
],
[
("ASN", info.connection.asn.map(|asn| format!("AS{asn}"))),
("Domain", info.connection.domain.clone()),
],
[
("Timezone", timezone),
("Lat/Lon", place(info.latitude, info.longitude)),
],
[
("Postal", info.postal.clone()),
(
"Calling",
info.calling_code.as_ref().map(|code| format!("+{code}")),
),
],
]
}
fn summary_lines(app: &App) -> Vec<Line<'static>> {
let mut rows: Vec<[Field; 2]> = app
.inspected_student()
.map(|s| {
[
("Name", Some(s.name.clone())),
("Dept", Some(s.dept.clone())),
]
})
.into_iter()
.collect();
let mut padding = 0;
if let Some(ip) = app.inspected_ip() {
match app.ip_lookups.get(&ip) {
Some(Some(Ok(info))) => rows.extend(ip_info_rows(&ip, info)),
Some(Some(Err(reason))) => {
rows.push([("IP", Some(ip.clone())), ("Lookup", Some(reason.clone()))]);
}
_ => {
rows.push([
("IP", Some(ip.clone())),
("Lookup", Some("looking up...".to_string())),
]);
padding = 5;
}
}
}
let mut lines = field_rows(rows);
lines.extend(std::iter::repeat_n(Line::default(), padding));
lines
}
fn wrap_line(line: Line<'static>, width: usize) -> Vec<Line<'static>> {
let mut rows: Vec<Vec<Span<'static>>> = vec![Vec::new()];
let mut used = 0;
for span in line.spans {
let mut rest: Vec<char> = span.content.chars().collect();
while !rest.is_empty() {
if used == width {
rows.push(Vec::new());
used = 0;
}
let take = rest.len().min(width - used);
used += take;
if let Some(row) = rows.last_mut() {
row.push(Span::styled(
rest.drain(..take).collect::<String>(),
span.style,
));
}
}
}
rows.into_iter().map(Line::from).collect()
}
fn inspector_layout(app: &App, area: Rect) -> InspectorLayout {
let (title, json) = inspected_item(app);
let lookup = summary_lines(app);
let details: Vec<Line<'static>> = json
.lines()
.map(|line| Line::styled(line.to_string(), WHITE))
.collect();
let content_width = lookup
.iter()
.chain(&details)
.map(Line::width)
.chain([
usize::from(hints_width(&INSPECTOR_HINTS, SEPARATOR)) + SCROLL_COUNTER_WIDTH,
title.chars().count() + 2,
])
.max()
.unwrap_or(0);
let inner_width = content_width
.min(usize::from(area.width.saturating_sub(6)))
.max(1);
let mut lines = lookup;
if !lines.is_empty() {
lines.push(Line::styled("─".repeat(inner_width), DIM));
}
lines.extend(details);
let rows: Vec<Line<'static>> = lines
.into_iter()
.flat_map(|line| wrap_line(line, inner_width))
.collect();
let modal = centered(
area,
u16::try_from(inner_width + 2).unwrap_or(u16::MAX),
u16::try_from(rows.len() + 3).unwrap_or(u16::MAX),
4,
);
InspectorLayout { title, rows, modal }
}
pub fn inspector_modal_area(app: &App, area: Rect) -> Rect {
inspector_layout(app, area).modal
}
pub fn inspector_max_scroll(app: &App, area: Rect) -> usize {
inspector_layout(app, area).max_scroll()
}
fn render_inspector_modal(app: &App, frame: &mut Frame, area: Rect) {
let layout = inspector_layout(app, area);
let modal = layout.modal;
frame.render_widget(Clear, modal);
let total = layout.rows.len();
let body_height = layout.body_height();
let scroll = app.inspector_scroll.min(layout.max_scroll());
let block = panel(layout.title, WHITE);
let inner = block.inner(modal);
frame.render_widget(block, modal);
let [body, hint_area] =
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
let text: Vec<Line> = layout
.rows
.into_iter()
.skip(scroll)
.take(body_height)
.collect();
frame.render_widget(Paragraph::new(text), body);
let mut hint = key_hints(&INSPECTOR_HINTS, SEPARATOR);
if total > body_height {
hint.push(Span::styled(
format!("{SEPARATOR}line {}/{}", scroll + 1, total),
DIM,
));
}
frame.render_widget(
Paragraph::new(Line::from(hint)).alignment(Alignment::Center),
hint_area,
);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{Terminal, backend::TestBackend};
fn rows_at(width: u16) -> (String, String) {
let worker = serde_json::from_value(serde_json::json!({
"id": "alpha", "ip": "10.0.0.1", "userAgent": "agent/1", "lastUpdateChecked": 1
}))
.expect("json");
let app = App {
workers: vec![worker],
..App::default()
};
let mut terminal = Terminal::new(TestBackend::new(width, 20)).expect("terminal");
terminal.draw(|f| render_ui(&app, f)).expect("draw");
let buffer = terminal.backend().buffer();
let line = |y| {
(0..width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
};
let lines: Vec<String> = (0..20).map(line).collect();
let find = |needle| {
lines
.iter()
.find(|l| l.contains(needle))
.cloned()
.unwrap_or_default()
};
(find("IDENT"), find("alpha"))
}
#[test]
fn worker_columns_align_at_every_width() {
for (width, columns) in [
(
120,
&[
("IDENT", "alpha"),
("IP", "10.0.0.1"),
("AGENT", "agent/1"),
("STATUS", "online"),
][..],
),
(
70,
&[("IDENT", "alpha"), ("IP", "10.0.0.1"), ("STATUS", "online")][..],
),
(40, &[("IDENT", "alpha"), ("STATUS", "online")][..]),
] {
let (header, row) = rows_at(width);
let column =
|line: &str, needle: &str| line.find(needle).map(|at| line[..at].chars().count());
for &(title, value) in columns {
assert_eq!(
column(&header, title),
column(&row, value),
"{title} misaligned at {width}: {row}"
);
}
assert_eq!(header.contains("CHECKED"), width >= 90);
assert_eq!(row.contains("d ago"), width >= 90);
}
}
}