use std::{
io::{self, Stdout, Write},
time::Duration,
};
use crossterm::{
cursor::{Hide, MoveTo, Show},
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
execute, queue,
style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor},
terminal::{
Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
enable_raw_mode, size,
},
};
use miette::{IntoDiagnostic, Result};
use tokio::sync::mpsc::UnboundedReceiver;
use bestool_alertd::doctor::{
check::{Check, CheckStatus},
progress::DoctorEvent,
};
use super::{SweepSource, order};
const SPINNER_FRAMES: &[&str] = &[
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
];
const TICK: Duration = Duration::from_millis(80);
#[derive(Clone)]
enum RowState {
Running,
Completed(Check),
}
struct TuiRow {
name: &'static str,
state: RowState,
}
pub struct TuiOutcome {
pub results: Vec<(Check, bool)>,
pub interrupted: bool,
}
#[derive(Default)]
struct Scroll {
offset: usize,
viewport: usize,
max: usize,
}
impl Scroll {
fn by(&mut self, delta: i64) {
let next = (self.offset as i64).saturating_add(delta).clamp(0, self.max as i64);
self.offset = next as usize;
}
}
#[derive(Clone, Debug)]
struct Segment {
text: String,
style: SegStyle,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SegStyle {
Plain,
Dim,
BoldFg(Color),
}
type StyledLine = Vec<Segment>;
fn plain<S: Into<String>>(s: S) -> Segment {
Segment {
text: s.into(),
style: SegStyle::Plain,
}
}
fn dim<S: Into<String>>(s: S) -> Segment {
Segment {
text: s.into(),
style: SegStyle::Dim,
}
}
fn bold_fg<S: Into<String>>(s: S, c: Color) -> Segment {
Segment {
text: s.into(),
style: SegStyle::BoldFg(c),
}
}
struct TerminalGuard {
stdout: Stdout,
}
impl TerminalGuard {
fn new() -> io::Result<Self> {
let mut stdout = io::stdout();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen, Hide)?;
Ok(Self { stdout })
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = execute!(self.stdout, Show, LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
pub async fn run_tui(
selected_names: Vec<&'static str>,
source: SweepSource,
mut progress_rx: UnboundedReceiver<DoctorEvent>,
) -> Result<TuiOutcome> {
let total = selected_names.len();
let mut rows: Vec<TuiRow> = selected_names
.into_iter()
.map(|name| TuiRow {
name,
state: RowState::Running,
})
.collect();
let mut spinner = 0usize;
let interrupted;
let mut scroll = Scroll::default();
let mut guard = TerminalGuard::new().into_diagnostic()?;
loop {
drain_progress(&mut progress_rx, &mut rows);
let finalising = all_completed(&rows);
draw(
&mut guard.stdout,
&rows,
total,
&source,
spinner,
finalising,
&mut scroll,
)
.into_diagnostic()?;
if progress_rx.is_closed() && progress_rx.is_empty() {
interrupted = !finalising;
break;
}
if poll_input(TICK, &mut scroll).into_diagnostic()? {
interrupted = true;
break;
}
spinner = (spinner + 1) % SPINNER_FRAMES.len();
}
drop(guard);
let results: Vec<(Check, bool)> = rows
.into_iter()
.filter_map(|row| match row.state {
RowState::Completed(check) => Some((check, true)),
RowState::Running => None,
})
.collect();
Ok(TuiOutcome {
results,
interrupted,
})
}
fn drain_progress(rx: &mut UnboundedReceiver<DoctorEvent>, rows: &mut [TuiRow]) {
while let Ok(evt) = rx.try_recv() {
match evt {
DoctorEvent::Completed(check) => {
if let Some(row) = rows.iter_mut().find(|r| r.name == check.name) {
row.state = RowState::Completed(check);
}
}
}
}
}
fn all_completed(rows: &[TuiRow]) -> bool {
rows.iter().all(|r| matches!(r.state, RowState::Completed(_)))
}
fn poll_input(timeout: Duration, scroll: &mut Scroll) -> io::Result<bool> {
if !event::poll(timeout)? {
return Ok(false);
}
while event::poll(Duration::ZERO)? {
if let Event::Key(KeyEvent {
code, modifiers, ..
}) = event::read()?
{
let ctrl_c =
matches!(code, KeyCode::Char('c')) && modifiers.contains(KeyModifiers::CONTROL);
if ctrl_c || matches!(code, KeyCode::Char('q')) {
return Ok(true);
}
let page = scroll.viewport.max(1) as i64;
match code {
KeyCode::Up | KeyCode::Char('k') => scroll.by(-1),
KeyCode::Down | KeyCode::Char('j') => scroll.by(1),
KeyCode::PageUp => scroll.by(-page),
KeyCode::PageDown | KeyCode::Char(' ') => scroll.by(page),
KeyCode::Home | KeyCode::Char('g') => scroll.by(i64::MIN),
KeyCode::End | KeyCode::Char('G') => scroll.by(i64::MAX),
_ => {}
}
}
}
Ok(false)
}
fn draw(
out: &mut Stdout,
rows: &[TuiRow],
total: usize,
source: &SweepSource,
spinner: usize,
finalising: bool,
scroll: &mut Scroll,
) -> io::Result<()> {
let term_rows = size().map(|(_, r)| r as usize).unwrap_or(24).max(2);
let body = build_rows(rows, spinner);
let viewport = term_rows.saturating_sub(2);
scroll.viewport = viewport;
scroll.max = body.len().saturating_sub(viewport);
scroll.offset = scroll.offset.min(scroll.max);
let end = (scroll.offset + viewport).min(body.len());
let visible = &body[scroll.offset..end];
queue!(out, MoveTo(0, 0))?;
write_line(out, &header_line(source))?;
queue!(out, Clear(ClearType::UntilNewLine))?;
out.write_all(b"\r\n")?;
for _ in 0..viewport.saturating_sub(visible.len()) {
queue!(out, Clear(ClearType::UntilNewLine))?;
out.write_all(b"\r\n")?;
}
for line in visible {
write_line(out, line)?;
queue!(out, Clear(ClearType::UntilNewLine))?;
out.write_all(b"\r\n")?;
}
write_line(out, &footer_line(rows, total, spinner, finalising, scroll))?;
queue!(out, Clear(ClearType::UntilNewLine))?;
out.flush()
}
fn write_line(out: &mut Stdout, line: &StyledLine) -> io::Result<()> {
for seg in line {
match seg.style {
SegStyle::Plain => out.write_all(seg.text.as_bytes())?,
SegStyle::Dim => {
queue!(out, SetAttribute(Attribute::Dim))?;
out.write_all(seg.text.as_bytes())?;
queue!(out, SetAttribute(Attribute::Reset))?;
}
SegStyle::BoldFg(c) => {
queue!(out, SetForegroundColor(c), SetAttribute(Attribute::Bold))?;
out.write_all(seg.text.as_bytes())?;
queue!(out, SetAttribute(Attribute::Reset), ResetColor)?;
}
}
}
Ok(())
}
fn header_line(source: &SweepSource) -> StyledLine {
let text = match source {
SweepSource::Local => "Source: local".to_string(),
SweepSource::DaemonStreamed => "Source: alertd daemon (just now, on demand)".to_string(),
SweepSource::DaemonCached { computed_at } => {
let age = super::render::humanise_age_since(*computed_at);
format!("Source: alertd daemon (computed {age} ago, at {computed_at})")
}
};
vec![dim(text)]
}
fn build_rows(rows: &[TuiRow], spinner: usize) -> Vec<StyledLine> {
let name_width = rows.iter().map(|r| r.name.len()).max().unwrap_or(0);
let mut ordered: Vec<&TuiRow> = rows
.iter()
.filter(|row| match &row.state {
RowState::Running => true,
RowState::Completed(check) => !matches!(check.status, CheckStatus::Skip(_)),
})
.collect();
ordered.sort_by(|a, b| sort_key(a).cmp(&sort_key(b)).then_with(|| a.name.cmp(b.name)));
let mut lines = Vec::with_capacity(ordered.len());
for row in ordered {
match &row.state {
RowState::Running => lines.push(row_line_running(row.name, name_width, spinner)),
RowState::Completed(check) => {
lines.push(row_line_completed(check, name_width));
if let Some(reason) = reason_for(check) {
lines.push(reason_line(reason, name_width));
}
}
}
}
lines
}
fn sort_key(row: &TuiRow) -> u8 {
match &row.state {
RowState::Running => 0,
RowState::Completed(check) => 1 + order::severity_key(&check.status),
}
}
fn row_line_running(name: &str, name_width: usize, spinner: usize) -> StyledLine {
let frame = SPINNER_FRAMES[spinner % SPINNER_FRAMES.len()];
let pad = " ".repeat(name_width.saturating_sub(name.len()));
vec![
plain(" "),
dim(format!("{frame:<4}")),
plain(" "),
plain(name.to_string()),
plain(pad),
plain(" "),
dim("…"),
]
}
fn row_line_completed(check: &Check, name_width: usize) -> StyledLine {
let (tag, color) = tag_for(check);
let pad = " ".repeat(name_width.saturating_sub(check.name.len()));
vec![
plain(" "),
bold_fg(format!("{tag:<4}"), color),
plain(" "),
plain(check.name.to_string()),
plain(pad),
plain(" "),
plain(check.summary.clone()),
]
}
fn reason_line(reason: &str, name_width: usize) -> StyledLine {
let lead = " ".repeat(10 + name_width + 5);
vec![plain(lead), dim(reason.to_string())]
}
fn reason_for(check: &Check) -> Option<&str> {
match &check.status {
CheckStatus::Pass => None,
CheckStatus::Skip(r)
| CheckStatus::Warning(r)
| CheckStatus::Fail(r)
| CheckStatus::Broken(r) => Some(r.as_str()),
}
}
fn tag_for(check: &Check) -> (&'static str, Color) {
match &check.status {
CheckStatus::Pass => ("PASS", Color::Green),
CheckStatus::Skip(_) => ("SKIP", Color::DarkGrey),
CheckStatus::Warning(_) => ("WARN", Color::Yellow),
CheckStatus::Broken(_) => ("BRKN", Color::Magenta),
CheckStatus::Fail(_) => ("FAIL", Color::Red),
}
}
fn footer_line(
rows: &[TuiRow],
total: usize,
spinner: usize,
finalising: bool,
scroll: &Scroll,
) -> StyledLine {
let completed = rows
.iter()
.filter(|r| matches!(r.state, RowState::Completed(_)))
.count();
let frame = SPINNER_FRAMES[spinner % SPINNER_FRAMES.len()];
let status = if finalising {
format!("{frame} finalising… ({completed} / {total} checks done)")
} else {
format!("{frame} {completed} / {total} complete")
};
let mut line = vec![dim(status)];
if scroll.max > 0 {
let above = scroll.offset;
let below = scroll.max - scroll.offset;
line.push(dim(format!(
" ↑/↓ scroll ({above} above, {below} below)"
)));
}
line
}
#[cfg(test)]
mod tests {
use super::*;
fn line_text(line: &StyledLine) -> String {
line.iter().map(|s| s.text.as_str()).collect()
}
#[test]
fn build_rows_orders_running_then_pass_warn_broken_fail() {
let rows = vec![
TuiRow {
name: "z-fail",
state: RowState::Completed(Check::fail("z-fail", "bad", "r")),
},
TuiRow {
name: "a-pass",
state: RowState::Completed(Check::pass("a-pass", "ok")),
},
TuiRow {
name: "m-warn",
state: RowState::Completed(Check::warning("m-warn", "deg", "r")),
},
TuiRow {
name: "k-run",
state: RowState::Running,
},
TuiRow {
name: "c-broken",
state: RowState::Completed(Check::broken("c-broken", "broke", "r")),
},
];
let lines = build_rows(&rows, 0);
let joined: Vec<String> = lines.iter().map(line_text).collect();
let positions = |needle: &str| {
joined
.iter()
.position(|s| s.contains(needle))
.unwrap_or_else(|| panic!("missing {needle}"))
};
assert!(positions("k-run") < positions("a-pass"));
assert!(positions("a-pass") < positions("m-warn"));
assert!(positions("m-warn") < positions("c-broken"));
assert!(positions("c-broken") < positions("z-fail"));
}
#[test]
fn build_rows_drops_completed_skip_but_keeps_pass_and_running() {
let rows = vec![
TuiRow {
name: "a-pass",
state: RowState::Completed(Check::pass("a-pass", "ok")),
},
TuiRow {
name: "b-skip",
state: RowState::Completed(Check::skip("b-skip", "n/a", "r")),
},
TuiRow {
name: "c-warn",
state: RowState::Completed(Check::warning("c-warn", "deg", "r")),
},
TuiRow {
name: "d-run",
state: RowState::Running,
},
];
let lines = build_rows(&rows, 0);
let joined: Vec<String> = lines.iter().map(line_text).collect();
assert!(joined.iter().any(|s| s.contains("d-run")));
assert!(joined.iter().any(|s| s.contains("c-warn")));
assert!(joined.iter().any(|s| s.contains("a-pass")));
assert!(!joined.iter().any(|s| s.contains("b-skip")));
}
#[test]
fn footer_counts_completed_against_total() {
let rows = vec![
TuiRow {
name: "a",
state: RowState::Completed(Check::pass("a", "ok")),
},
TuiRow {
name: "b",
state: RowState::Completed(Check::warning("b", "deg", "r")),
},
TuiRow {
name: "c",
state: RowState::Running,
},
];
let line = footer_line(&rows, 3, 0, false, &Scroll::default());
assert!(line_text(&line).contains("2 / 3 complete"));
}
#[test]
fn footer_shows_finalising_when_all_done() {
let rows = vec![TuiRow {
name: "a",
state: RowState::Completed(Check::pass("a", "ok")),
}];
let line = footer_line(&rows, 1, 0, true, &Scroll::default());
assert!(line_text(&line).contains("finalising"));
}
#[test]
fn footer_shows_scroll_hint_only_when_scrollable() {
let rows = vec![TuiRow {
name: "a",
state: RowState::Running,
}];
let none = footer_line(&rows, 1, 0, false, &Scroll::default());
assert!(!line_text(&none).contains("scroll"));
let scroll = Scroll {
offset: 2,
viewport: 5,
max: 7,
};
let some = footer_line(&rows, 1, 0, false, &scroll);
let text = line_text(&some);
assert!(text.contains("2 above"));
assert!(text.contains("5 below"));
}
#[test]
fn scroll_by_clamps_to_range() {
let mut s = Scroll {
offset: 0,
viewport: 5,
max: 4,
};
s.by(-1);
assert_eq!(s.offset, 0);
s.by(2);
assert_eq!(s.offset, 2);
s.by(100);
assert_eq!(s.offset, 4);
s.by(i64::MIN);
assert_eq!(s.offset, 0);
}
}