use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::symbols::border;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use super::super::app::{App, Health, PluginFocus};
use super::super::theme;
use super::format::spinner_frame;
use super::panes::{draw_scrollbar, empty_state, section_box, selector_width};
use crate::format::truncate;
pub(super) fn draw(frame: &mut Frame<'_>, area: Rect, app: &App) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(selector_width(area.width)),
Constraint::Min(20),
])
.split(area);
draw_selector(frame, cols[0], app);
draw_detail(frame, cols[1], app);
}
fn draw_selector(frame: &mut Frame<'_>, area: Rect, app: &App) {
let focused = app.plugin.focus == PluginFocus::List;
let block = list_block(app, focused);
let inner = block.inner(area);
frame.render_widget(block, area);
if app.plugin.row_count() == 0 {
let widget = if app.plugin.error.is_some() {
empty_state("check failed", "r", "to retry")
} else {
empty_state("no checks yet", "r", "to run")
};
frame.render_widget(widget, inner);
return;
}
let content_w = inner.width as usize;
let mut rows: Vec<Line<'static>> = Vec::new();
let mut cursor_line = 0usize;
for (idx, check) in app.plugin.checks.iter().enumerate() {
if idx == app.plugin.cursor {
cursor_line = rows.len();
}
rows.push(selector_row(
check.health,
check.label,
theme::body(),
"",
0,
idx == app.plugin.cursor,
focused,
content_w,
check.fix.is_some(),
));
}
let viewport = inner.height as usize;
let start = window_start(cursor_line, viewport, rows.len());
let shown = rows.len().saturating_sub(start).min(viewport.max(1));
let window: Vec<Line<'static>> = rows.iter().skip(start).take(shown).cloned().collect();
frame.render_widget(Paragraph::new(window).style(theme::base()), inner);
draw_scrollbar(frame, inner, rows.len(), start, viewport);
}
fn window_start(focus: usize, viewport: usize, total: usize) -> usize {
if total <= viewport || viewport == 0 {
return 0;
}
let half = viewport / 2;
if focus < half {
0
} else {
focus.saturating_sub(half).min(total - viewport)
}
}
#[allow(clippy::too_many_arguments)]
fn selector_row(
health: Health,
label: &str,
label_style: Style,
value: &str,
label_pad: usize,
selected: bool,
focused: bool,
content_w: usize,
has_fix: bool,
) -> Line<'static> {
let tint = selected.then(theme::bg_hover);
let with_bg = |style: Style| match tint {
Some(color) => style.bg(color),
None => style,
};
let caret = if selected && focused {
Span::styled(
"❯ ",
with_bg(
Style::default()
.fg(theme::accent_color())
.add_modifier(Modifier::BOLD),
),
)
} else {
Span::styled(" ", with_bg(Style::default()))
};
let dot = Span::styled("● ", with_bg(Style::default().fg(health_color(health))));
let label_style = if selected && focused {
with_bg(label_style.add_modifier(Modifier::BOLD))
} else {
with_bg(label_style)
};
let label_len = label.chars().count();
let align = label_pad.saturating_sub(label_len);
let head_w = 4 + label_len + align;
let mut spans = vec![caret, dot, Span::styled(label.to_string(), label_style)];
if align > 0 {
spans.push(Span::styled(" ".repeat(align), with_bg(Style::default())));
}
let marker_reserve = if has_fix { 4 } else { 0 };
let value_room = content_w.saturating_sub(head_w + 2 + marker_reserve);
if value_room > 0 && !value.is_empty() {
spans.push(Span::styled(" ".to_string(), with_bg(Style::default())));
spans.push(Span::styled(
truncate(value, value_room),
with_bg(theme::dim()),
));
}
if has_fix {
pad_to(&mut spans, content_w.saturating_sub(3), tint);
spans.push(Span::styled(
"[f]".to_string(),
with_bg(theme::accent().add_modifier(Modifier::BOLD)),
));
} else {
pad_to(&mut spans, content_w, tint);
}
Line::from(spans)
}
fn pad_to(spans: &mut Vec<Span<'static>>, content_w: usize, tint: Option<ratatui::style::Color>) {
let used: usize = spans.iter().map(|s| s.content.chars().count()).sum();
let pad = content_w.saturating_sub(used);
if pad > 0 {
let style = match tint {
Some(color) => Style::default().bg(color),
None => Style::default(),
};
spans.push(Span::styled(" ".repeat(pad), style));
}
}
fn list_block(app: &App, focused: bool) -> Block<'static> {
let border_color = if focused {
theme::line_strong_color()
} else {
theme::line_color()
};
let mut title_mods = Modifier::ITALIC;
if focused {
title_mods |= Modifier::BOLD;
}
let title_style = Style::default()
.fg(theme::accent_2_color())
.add_modifier(title_mods);
let mut title_spans = vec![Span::styled(" PLUGIN ", title_style)];
if app.plugin.fetching {
title_spans.push(Span::styled(
format!("{} ", spinner_frame(app.tick_count)),
theme::accent(),
));
}
Block::default()
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(border_color))
.title(Line::from(title_spans))
.padding(ratatui::widgets::Padding::horizontal(1))
}
fn draw_detail(frame: &mut Frame<'_>, area: Rect, app: &App) {
let focused = app.plugin.focus == PluginFocus::Detail;
let (block, detail): (Block<'static>, &[String]) =
if let Some(check) = app.plugin.selected_check() {
(
section_box(check.label, focused, false),
check.detail.as_slice(),
)
} else {
(section_box("plugin", focused, false), [].as_slice())
};
let inner = block.inner(area);
frame.render_widget(block, area);
if detail.is_empty() {
let hint = Paragraph::new(Line::from(Span::styled("no row selected", theme::dim())))
.style(theme::base());
frame.render_widget(hint, inner);
return;
}
let key_w = detail
.iter()
.filter(|line| !line.starts_with(" "))
.filter_map(|line| line.split_once(": ").map(|(k, _)| k.chars().count()))
.max()
.unwrap_or(0)
.min(18);
let lines: Vec<Line<'static>> = detail.iter().map(|line| detail_line(line, key_w)).collect();
let total = lines.len();
let viewport = inner.height as usize;
let max_scroll = total.saturating_sub(viewport).min(u16::MAX as usize) as u16;
app.plugin.detail_max_scroll.set(max_scroll);
let scroll = app.plugin.detail_scroll.min(max_scroll);
frame.render_widget(
Paragraph::new(lines)
.style(theme::base())
.scroll((scroll, 0)),
inner,
);
draw_scrollbar(frame, inner, total, scroll as usize, viewport);
}
fn detail_line(text: &str, key_w: usize) -> Line<'static> {
if text.is_empty() {
return Line::from("");
}
if let Some(rest) = text.strip_prefix("[f]") {
let label = rest.trim_start();
return Line::from(vec![
Span::styled("[f]", theme::accent().add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(label.to_string(), theme::dim()),
]);
}
if text.starts_with(" ") {
return Line::from(Span::styled(text.to_string(), theme::dim()));
}
if let Some((key, value)) = text.split_once(": ") {
let pad = key_w.saturating_sub(key.chars().count()) + 2;
return Line::from(vec![
Span::styled(format!("{key}{}", " ".repeat(pad)), theme::label()),
Span::styled(value.to_string(), value_tone(key, value)),
]);
}
Line::from(Span::styled(text.to_string(), theme::body()))
}
fn value_tone(key: &str, value: &str) -> Style {
if value.contains("rate-limited") || value.contains("degraded") {
return theme::warning();
}
let head = value.split_whitespace().next().unwrap_or(value);
match (key, head) {
("present" | "installed", "yes") => theme::success(),
("present" | "installed", "no") => theme::warning(),
("server", "boots") => theme::success(),
("server", "failed") => theme::danger(),
("link", "linked") => theme::success(),
("link", "diverged") => theme::warning(),
("link", "missing") => theme::danger(),
_ => theme::body(),
}
}
fn health_color(health: Health) -> ratatui::style::Color {
match health {
Health::Ok => theme::success_color(),
Health::Warn => theme::warning_color(),
Health::Danger => theme::danger_color(),
Health::Idle => theme::text_dim_color(),
}
}