use ratatui::{
prelude::*,
widgets::{Block, Clear, Paragraph, Wrap},
};
use crate::design as d;
use crate::divergence::{verdict_for, HostDivergence};
use crate::theme::Theme;
pub fn render(
f: &mut Frame,
area: Rect,
divergence: Option<&HostDivergence>,
collectable: (usize, usize, usize),
platform: &str,
theme: &Theme,
) {
f.render_widget(
Block::default().style(Style::default().bg(d::BG).fg(d::FAINT)),
area,
);
let lines = match divergence {
Some(dv) => body(dv, collectable, platform, theme),
None => vec![
Line::from(Span::styled(
"No peer set for this host.",
Style::default().fg(d::WHITE).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"Divergence compares a host against others sharing a tag. Tag two \
or more hosts alike — role=web, env=prod — and they become peers.",
Style::default().fg(d::DIM),
)),
],
};
let width = 86u16.min(area.width.saturating_sub(6));
let wrapped = estimate_wrapped(&lines, width.saturating_sub(4));
let height = (wrapped + 5).min(area.height.saturating_sub(4));
let x = (area.width.saturating_sub(width)) / 2;
let y = (area.height.saturating_sub(height)) * 46 / 100;
let card = Rect::new(x, y, width, height);
f.render_widget(Clear, card);
f.render_widget(
Block::default().style(Style::default().bg(d::RULE).fg(d::FG)),
card,
);
let inner = Rect {
x: card.x + 2,
y: card.y + 1,
width: card.width.saturating_sub(4),
height: card.height.saturating_sub(2),
};
let body_area = Rect {
height: inner.height.saturating_sub(1),
..inner
};
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), body_area);
if inner.height > 1 {
let hint = Rect {
x: inner.x,
y: inner.y + inner.height - 1,
width: inner.width,
height: 1,
};
f.render_widget(
Paragraph::new(d::footer_line(&[("⎋", "close"), ("D", "toggle")])),
hint,
);
}
}
fn body<'a>(
d: &'a HostDivergence,
collectable: (usize, usize, usize),
platform: &'a str,
_theme: &Theme,
) -> Vec<Line<'a>> {
let mut lines = Vec::new();
lines.push(Line::from(vec![
Span::styled(
d.host.clone(),
Style::default().fg(d::WHITE).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" vs {}", d.peer_set), Style::default().fg(d::DIM)),
]));
let (usable, total, privileged) = collectable;
let mut coverage = format!("{} of {} facets collectable on {}", usable, total, platform);
if privileged > 0 {
coverage.push_str(&format!(
" · {} need privileges and may report permission denied",
privileged
));
}
lines.push(Line::from(Span::styled(
coverage,
Style::default().fg(d::DIM),
)));
lines.push(Line::from(""));
if !d.is_probed() {
lines.push(Line::from(Span::styled(
"Never probed — no facts to compare.",
Style::default().fg(d::FAINT).add_modifier(Modifier::ITALIC),
)));
return lines;
}
if let Some(v) = verdict_for(d) {
lines.push(Line::from(Span::styled(
"VERDICT",
Style::default().fg(d::CYAN).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(v.text, Style::default().fg(d::FG))));
lines.push(Line::from(Span::styled(
format!(
" evidence: {}",
v.evidence
.iter()
.map(|k| k.label())
.collect::<Vec<_>>()
.join(", ")
),
Style::default().fg(d::FAINT).add_modifier(Modifier::ITALIC),
)));
lines.push(Line::from(""));
}
let diverging = d.diverging();
if diverging.is_empty() {
lines.push(Line::from(Span::styled(
"At consensus on every collected facet.",
Style::default().fg(d::GREEN),
)));
} else {
lines.push(Line::from(Span::styled(
"DIVERGING",
Style::default().fg(d::CYAN).add_modifier(Modifier::BOLD),
)));
for c in &diverging {
let colour = d::divergence(c.severity);
lines.push(Line::from(vec![
Span::styled(
format!(" {:<20} ", c.key.label()),
Style::default().fg(d::DIM),
),
Span::styled(c.summary(), Style::default().fg(colour)),
]));
}
lines.push(Line::from(""));
}
if !d.identical.is_empty() {
let names: Vec<String> = d.identical.iter().map(|k| k.label()).collect();
lines.push(Line::from(Span::styled(
format!("{} facets identical across peers", d.identical.len()),
Style::default().fg(d::DIM),
)));
lines.push(Line::from(Span::styled(
format!(" {}", names.join(" · ")),
Style::default().fg(d::FAINT).add_modifier(Modifier::ITALIC),
)));
}
if !d.unprobed_peers.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(
"{} peers never probed — excluded from every count above: {}",
d.unprobed_peers.len(),
d.unprobed_peers.join(", ")
),
Style::default().fg(d::FAINT).add_modifier(Modifier::ITALIC),
)));
}
lines
}
fn estimate_wrapped(lines: &[Line], width: u16) -> u16 {
if width == 0 {
return lines.len() as u16;
}
lines
.iter()
.map(|l| {
let w: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
(w.max(1).div_ceil(width as usize)) as u16
})
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrapping_is_accounted_for_so_content_is_not_clipped() {
let long = "x".repeat(200);
let lines = vec![
Line::from("short"),
Line::from(long.as_str()),
Line::from("short"),
];
assert_eq!(estimate_wrapped(&lines, 80), 1 + 3 + 1);
}
#[test]
fn an_empty_line_still_occupies_a_row() {
assert_eq!(estimate_wrapped(&[Line::from("")], 40), 1);
}
}