use std::collections::VecDeque;
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
use std::sync::mpsc::{self, RecvTimeoutError, SyncSender};
use std::time::{Duration, Instant};
use ansi_to_tui::IntoText;
use crossterm::{cursor, execute, terminal};
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
Frame,
};
pub(crate) const MIN_PANE_HEIGHT: usize = 9;
pub(crate) fn tui_layout(n: usize, term_h: usize) -> (usize, usize) {
if n == 0 || term_h == 0 {
return (0, MIN_PANE_HEIGHT);
}
if n * MIN_PANE_HEIGHT <= term_h {
(n, term_h / n)
} else {
let visible = term_h.saturating_sub(1) / MIN_PANE_HEIGHT;
(visible, MIN_PANE_HEIGHT)
}
}
enum TuiMsg {
SlotStarted { slot_idx: usize },
SlotSkipped { slot_idx: usize, reason: String },
Line { slot_idx: usize, line: String },
SlotDone { slot_idx: usize, success: bool },
Shutdown,
}
pub(crate) struct TuiSlot {
slot_idx: usize,
sender: SyncSender<TuiMsg>,
log: Mutex<std::fs::File>,
}
impl crate::parallel::LineSink for TuiSlot {
fn start(&self) {
let _ = self.sender.send(TuiMsg::SlotStarted { slot_idx: self.slot_idx });
}
fn skip(&self, reason: &str) {
let _ = self.sender.send(TuiMsg::SlotSkipped {
slot_idx: self.slot_idx,
reason: reason.to_string(),
});
}
fn push_line(&self, line: String) {
if let Ok(mut f) = self.log.lock() {
let _ = writeln!(f, "{}", line);
}
let _ = self.sender.send(TuiMsg::Line { slot_idx: self.slot_idx, line });
}
fn flush(&self) {}
fn complete(&self, success: bool) {
let _ = self.sender.send(TuiMsg::SlotDone { slot_idx: self.slot_idx, success });
}
fn prefix_visual_len(&self) -> usize {
0
}
}
pub(crate) struct TuiRenderer {
sender: SyncSender<TuiMsg>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl TuiRenderer {
pub(crate) fn new(
names: Vec<String>,
log_files: Vec<std::fs::File>,
visible_count: usize,
) -> (Self, Vec<Arc<TuiSlot>>) {
let (sender, receiver) = mpsc::sync_channel::<TuiMsg>(1000);
let slots: Vec<Arc<TuiSlot>> = log_files
.into_iter()
.enumerate()
.map(|(i, log)| {
Arc::new(TuiSlot {
slot_idx: i,
sender: sender.clone(),
log: Mutex::new(log),
})
})
.collect();
let thread = std::thread::spawn(move || {
render_loop(receiver, names, visible_count);
});
let renderer = TuiRenderer { sender, thread: Some(thread) };
(renderer, slots)
}
}
impl Drop for TuiRenderer {
fn drop(&mut self) {
let _ = self.sender.send(TuiMsg::Shutdown);
if let Some(t) = self.thread.take() {
t.join().ok();
}
}
}
struct SlotData {
name: String,
done: Option<bool>,
skipped: bool,
ring: VecDeque<String>,
}
struct RenderState {
slots: Vec<SlotData>,
pane_to_slot: Vec<usize>,
background_queue: VecDeque<usize>,
visible: usize,
skip_reason: Option<String>,
}
fn render_loop(
rx: mpsc::Receiver<TuiMsg>,
names: Vec<String>,
visible: usize,
) {
let stdout = io::stdout();
let backend = CrosstermBackend::new(stdout);
let mut terminal = match Terminal::new(backend) {
Ok(t) => t,
Err(_) => return,
};
let _ = execute!(io::stdout(), terminal::EnterAlternateScreen);
let _ = execute!(io::stdout(), cursor::Hide);
let _ = write!(io::stdout(), "\x1b[?7l");
let _ = terminal.clear();
let mut state = RenderState {
slots: names
.into_iter()
.map(|name| SlotData { name, done: None, skipped: false, ring: VecDeque::new() })
.collect(),
pane_to_slot: Vec::new(),
background_queue: VecDeque::new(),
visible,
skip_reason: None,
};
let _ = terminal.draw(|f| render_frame(f, &state));
let mut pending: std::collections::VecDeque<TuiMsg> = std::collections::VecDeque::new();
let mut dbg = DbgLog::open();
loop {
let msg = match pending.pop_front().or_else(|| rx.recv().ok()) {
Some(m) => m,
None => break,
};
if dbg.is_on() {
dbg.log(&format!("RECV pending_remaining={} msg={}", pending.len(), describe_msg(&msg)));
}
match msg {
TuiMsg::SlotStarted { slot_idx } => {
note_started(&mut state, slot_idx);
let _ = terminal.draw(|f| render_frame(f, &state));
}
TuiMsg::SlotSkipped { slot_idx, reason } => {
note_skipped(&mut state, slot_idx, reason);
let _ = terminal.draw(|f| render_frame(f, &state));
}
TuiMsg::Line { slot_idx, line } => {
state.slots[slot_idx].ring.push_back(line);
drain_available(&rx, &mut state, &mut pending);
let _ = terminal.draw(|f| render_frame(f, &state));
}
TuiMsg::SlotDone { slot_idx, success } => {
state.slots[slot_idx].done = Some(success);
let _ = terminal.draw(|f| render_frame(f, &state));
if let Some(pane_idx) =
state.pane_to_slot.iter().position(|&s| s == slot_idx)
{
if success && !state.background_queue.is_empty() {
if let Some(stashed) =
drain_hold(&rx, &mut terminal, &mut state, 1)
{
if dbg.is_on() {
dbg.log(&format!("DRAIN_HOLD(1s) pending={} stashed={}", pending.len(), describe_msg(&stashed)));
}
match stashed {
TuiMsg::Shutdown => {
log_apply_shutdown(&mut dbg, &state, &pending);
resolve_outstanding_done_messages(&rx, &mut state, &pending);
apply_shutdown(&mut state);
let _ = terminal.draw(|f| render_frame(f, &state));
break;
}
TuiMsg::SlotDone { slot_idx: s, success: ok }
if state.pane_to_slot.iter().all(|&x| x != s) =>
{
state.slots[s].done = Some(ok);
state.background_queue.retain(|&x| x != s);
if !ok {
place_failed_in_pane(&mut state, s);
}
let _ = terminal.draw(|f| render_frame(f, &state));
}
other => pending.push_back(other),
}
}
let next = loop {
match state.background_queue.pop_front() {
None => break None,
Some(s) if state.slots[s].done.is_none() => break Some(s),
Some(_) => continue,
}
};
if let Some(next) = next {
state.pane_to_slot[pane_idx] = next;
} else {
state.pane_to_slot.remove(pane_idx);
}
drain_available(&rx, &mut state, &mut pending);
if dbg.is_on() {
dbg.log(&format!("DRAIN_AVAIL pending_now={}", pending.len()));
}
let _ = terminal.draw(|f| render_frame(f, &state));
} else if success {
if let Some(stashed) =
drain_hold(&rx, &mut terminal, &mut state, 2)
{
if dbg.is_on() {
dbg.log(&format!("DRAIN_HOLD(2s) pending={} stashed={}", pending.len(), describe_msg(&stashed)));
}
match stashed {
TuiMsg::Shutdown => {
log_apply_shutdown(&mut dbg, &state, &pending);
resolve_outstanding_done_messages(&rx, &mut state, &pending);
apply_shutdown(&mut state);
let _ = terminal.draw(|f| render_frame(f, &state));
break;
}
TuiMsg::SlotDone { slot_idx: s, success: ok }
if state.pane_to_slot.iter().all(|&x| x != s) =>
{
state.slots[s].done = Some(ok);
state.background_queue.retain(|&x| x != s);
if !ok {
place_failed_in_pane(&mut state, s);
}
let _ = terminal.draw(|f| render_frame(f, &state));
}
other => pending.push_back(other),
}
}
state.pane_to_slot.remove(pane_idx);
let _ = terminal.draw(|f| render_frame(f, &state));
}
} else {
state.background_queue.retain(|&s| s != slot_idx);
if !success {
place_failed_in_pane(&mut state, slot_idx);
}
let _ = terminal.draw(|f| render_frame(f, &state));
}
}
TuiMsg::Shutdown => {
log_apply_shutdown(&mut dbg, &state, &pending);
resolve_outstanding_done_messages(&rx, &mut state, &pending);
apply_shutdown(&mut state);
let _ = terminal.draw(|f| render_frame(f, &state));
break;
}
}
}
let _ = write!(io::stdout(), "\x1b[r\x1b[?7h\x1b]8;;\x07");
let _ = execute!(io::stdout(), terminal::LeaveAlternateScreen, cursor::Show);
print_failed_tails(&state, TAIL_LINES, &mut io::stdout());
print_build_summary(&state, &mut io::stdout());
let _ = io::stdout().flush();
}
fn note_started(state: &mut RenderState, slot_idx: usize) {
if state.pane_to_slot.len() < state.visible {
state.pane_to_slot.push(slot_idx);
} else {
state.background_queue.push_back(slot_idx);
}
}
fn note_skipped(state: &mut RenderState, slot_idx: usize, reason: String) {
state.slots[slot_idx].skipped = true;
if state.skip_reason.is_none() {
state.skip_reason = Some(reason);
}
}
const TAIL_LINES: usize = 15;
fn resolve_outstanding_done_messages(
rx: &mpsc::Receiver<TuiMsg>,
state: &mut RenderState,
pending: &std::collections::VecDeque<TuiMsg>,
) {
for msg in pending {
if let TuiMsg::SlotDone { slot_idx, success } = msg {
if state.slots[*slot_idx].done.is_none() {
state.slots[*slot_idx].done = Some(*success);
}
}
}
loop {
match rx.try_recv() {
Ok(TuiMsg::SlotDone { slot_idx, success }) => {
if state.slots[slot_idx].done.is_none() {
state.slots[slot_idx].done = Some(success);
}
}
Ok(_) => {} Err(_) => break,
}
}
}
struct DbgLog(Option<std::io::BufWriter<std::fs::File>>);
impl DbgLog {
fn open() -> Self {
if std::env::var_os("CURIE_TUI_DEBUG").is_some() {
let f = std::fs::OpenOptions::new()
.create(true).append(true)
.open("/tmp/curie-tui-debug.log").ok();
DbgLog(f.map(std::io::BufWriter::new))
} else {
DbgLog(None)
}
}
fn log(&mut self, msg: &str) {
if let Some(ref mut w) = self.0 {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let _ = writeln!(w, "[{:.3}] {}", t.as_secs_f64(), msg);
let _ = w.flush();
}
}
fn is_on(&self) -> bool { self.0.is_some() }
}
fn describe_msg(msg: &TuiMsg) -> String {
match msg {
TuiMsg::SlotStarted { slot_idx } => format!("SlotStarted(slot={slot_idx})"),
TuiMsg::SlotSkipped { slot_idx, reason }
=> format!("SlotSkipped(slot={slot_idx} reason={reason:?})"),
TuiMsg::Line { slot_idx, .. } => format!("Line(slot={slot_idx})"),
TuiMsg::SlotDone { slot_idx, success }
=> format!("SlotDone(slot={slot_idx} ok={success})"),
TuiMsg::Shutdown => "Shutdown".to_string(),
}
}
fn log_apply_shutdown(dbg: &mut DbgLog, state: &RenderState, pending: &std::collections::VecDeque<TuiMsg>) {
if !dbg.is_on() { return; }
let none_slots: Vec<usize> = state.slots.iter().enumerate()
.filter(|(_, s)| s.done.is_none() && !s.skipped)
.map(|(i, _)| i)
.collect();
let pending_done: Vec<String> = pending.iter()
.filter_map(|m| if let TuiMsg::SlotDone { slot_idx, success } = m {
Some(format!("SD({slot_idx} ok={success})"))
} else { None })
.collect();
dbg.log(&format!(
"APPLY_SHUTDOWN none_slots={none_slots:?} pending_done=[{}]",
pending_done.join(", ")
));
}
fn print_failed_tails(state: &RenderState, max_lines: usize, out: &mut dyn io::Write) {
let failed: Vec<&SlotData> = state
.slots
.iter()
.filter(|s| s.done == Some(false))
.collect();
if failed.is_empty() {
return;
}
const NO_COL_LIMIT: usize = usize::MAX >> 1;
for slot in &failed {
let _ = writeln!(out, "\x1b[1;31m── {} ──\x1b[0m", slot.name);
let skip = slot.ring.len().saturating_sub(max_lines);
for line in slot.ring.iter().skip(skip) {
let _ = writeln!(out, " {}", truncate_to_cols(line, NO_COL_LIMIT));
}
let _ = writeln!(out);
}
}
fn print_summary_group(
out: &mut dyn io::Write,
label: &str,
names: &[&str],
label_ansi: &str,
name_ansi: &str,
term_w: usize,
) {
if names.is_empty() {
return;
}
let prefix = " \u{00b7} ";
let prefix_len = 4;
let budget = term_w.saturating_sub(prefix_len + label.len());
let body = build_overflow_names(names, budget);
let _ = writeln!(out, "{prefix}{label_ansi}{label}{name_ansi}{body}\x1b[0m");
}
fn print_build_summary(state: &RenderState, out: &mut dyn io::Write) {
let term_w = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80);
let skipped: Vec<&str> = state.slots.iter()
.filter(|s| s.skipped)
.map(|s| s.name.as_str())
.collect();
let done_ok: Vec<&str> = state.slots.iter()
.filter(|s| s.done == Some(true))
.map(|s| s.name.as_str())
.collect();
let done_err: Vec<&str> = state.slots.iter()
.filter(|s| s.done == Some(false))
.map(|s| s.name.as_str())
.collect();
let skip_label = match state.skip_reason.as_deref() {
Some(r) => format!("Skipped ({}): ", r),
None => "Skipped: ".to_string(),
};
print_summary_group(out, &skip_label, &skipped, "\x1b[33m", "\x1b[2m", term_w);
print_summary_group(out, "Done: ", &done_ok, "\x1b[1;32m", "\x1b[32m", term_w);
print_summary_group(out, "Failed: ", &done_err, "\x1b[1;31m", "\x1b[31m", term_w);
}
fn apply_shutdown(state: &mut RenderState) {
state.pane_to_slot.retain(|&s| state.slots[s].done != Some(true));
for slot in &mut state.slots {
if slot.done.is_none() {
slot.skipped = true;
}
}
}
fn place_failed_in_pane(state: &mut RenderState, slot_idx: usize) {
if state.pane_to_slot.len() < state.visible {
state.pane_to_slot.push(slot_idx);
return;
}
if let Some(pane_idx) = state
.pane_to_slot
.iter()
.position(|&s| state.slots[s].done.is_none())
{
let demoted = state.pane_to_slot[pane_idx];
state.pane_to_slot[pane_idx] = slot_idx;
state.background_queue.push_front(demoted);
}
}
fn distribute_pane_heights(total: u16, count: usize) -> Vec<u16> {
if count == 0 {
return Vec::new();
}
let count = count as u16;
let base = total / count;
let extra = total % count;
(0..count)
.map(|i| base + if i < extra { 1 } else { 0 })
.collect()
}
fn drain_hold(
rx: &mpsc::Receiver<TuiMsg>,
terminal: &mut ratatui::Terminal<CrosstermBackend<io::Stdout>>,
state: &mut RenderState,
hold_secs: u64,
) -> Option<TuiMsg> {
let deadline = Instant::now() + Duration::from_secs(hold_secs);
let frame_interval = Duration::from_millis(33); let mut last_draw = Instant::now() - frame_interval;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return None;
}
match rx.recv_timeout(remaining) {
Ok(TuiMsg::Line { slot_idx, line }) => {
state.slots[slot_idx].ring.push_back(line);
if last_draw.elapsed() >= frame_interval {
let _ = terminal.draw(|f| render_frame(f, state));
last_draw = Instant::now();
}
}
Ok(TuiMsg::SlotStarted { slot_idx }) => {
note_started(state, slot_idx);
let _ = terminal.draw(|f| render_frame(f, state));
}
Ok(TuiMsg::SlotSkipped { slot_idx, reason }) => {
note_skipped(state, slot_idx, reason);
let _ = terminal.draw(|f| render_frame(f, state));
}
Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => {
return None;
}
Ok(other) => return Some(other),
}
}
}
fn drain_available(
rx: &mpsc::Receiver<TuiMsg>,
state: &mut RenderState,
pending: &mut std::collections::VecDeque<TuiMsg>,
) {
loop {
match rx.try_recv() {
Ok(TuiMsg::Line { slot_idx, line }) => {
state.slots[slot_idx].ring.push_back(line);
}
Ok(TuiMsg::SlotStarted { slot_idx }) => {
note_started(state, slot_idx);
}
Ok(TuiMsg::SlotSkipped { slot_idx, reason }) => {
note_skipped(state, slot_idx, reason);
}
Ok(other) => {
pending.push_back(other);
break;
}
Err(_) => break,
}
}
}
fn render_frame(f: &mut Frame, state: &RenderState) {
let area = f.area();
let groups = classify_overflow_slots(state);
let overflow_rows = count_nonempty_groups(&groups) as u16;
let avail = area.height.saturating_sub(overflow_rows);
let heights = distribute_pane_heights(avail, state.pane_to_slot.len());
let mut constraints: Vec<Constraint> =
heights.iter().map(|&h| Constraint::Length(h)).collect();
for _ in 0..overflow_rows {
constraints.push(Constraint::Length(1));
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
for (pane_idx, &slot_idx) in state.pane_to_slot.iter().enumerate() {
let pane_area = chunks[pane_idx];
let slot = &state.slots[slot_idx];
render_pane(f, pane_area, &slot.name, slot.done, &slot.ring);
}
let base = state.pane_to_slot.len();
render_overflow_lines(
f,
&chunks[base..],
area.width as usize,
&groups,
state.skip_reason.as_deref(),
);
}
fn render_pane(
f: &mut Frame,
area: Rect,
name: &str,
done: Option<bool>,
ring: &VecDeque<String>,
) {
let (color, status) = match done {
None => (Color::Cyan, ""),
Some(true) => (Color::Green, " ✓"),
Some(false) => (Color::Red, " ✗"),
};
let border_style = match done {
None => Style::new().fg(color).add_modifier(Modifier::DIM),
_ => Style::new().fg(color).add_modifier(Modifier::BOLD),
};
let title_style = Style::new().fg(color).add_modifier(Modifier::BOLD);
let title_left = Line::from(vec![
Span::styled(format!(" {} ", name), title_style),
]);
let title_right = Line::from(vec![
Span::styled(format!("{} ", status), title_style),
]);
let block = Block::new()
.borders(Borders::ALL)
.border_style(border_style)
.title(title_left)
.title_alignment(Alignment::Left)
.title_bottom(title_right.alignment(Alignment::Right));
let inner = block.inner(area);
f.render_widget(block, area);
render_content(f, inner, ring);
}
fn render_content(f: &mut Frame, area: Rect, ring: &VecDeque<String>) {
let rows = area.height as usize;
let cols = area.width as usize;
let ring_len = ring.len();
let mut lines: Vec<Line<'static>> = Vec::with_capacity(rows);
for i in 0..rows {
if i + ring_len >= rows {
let ring_idx = i + ring_len - rows;
if ring_idx < ring_len {
let sanitized = truncate_to_cols(&ring[ring_idx], cols);
let line = parse_ansi_line(&sanitized);
lines.push(line);
continue;
}
}
lines.push(Line::default());
}
f.render_widget(Paragraph::new(Text::from(lines)), area);
}
struct OverflowGroups<'a> {
running: Vec<&'a str>,
pending: Vec<&'a str>,
skipped: Vec<&'a str>,
done_ok: Vec<&'a str>,
done_err: Vec<&'a str>,
}
fn classify_overflow_slots(state: &RenderState) -> OverflowGroups<'_> {
let visible_set: std::collections::HashSet<usize> =
state.pane_to_slot.iter().copied().collect();
let in_queue: std::collections::HashSet<usize> =
state.background_queue.iter().copied().collect();
let mut g = OverflowGroups {
running: Vec::new(),
pending: Vec::new(),
skipped: Vec::new(),
done_ok: Vec::new(),
done_err: Vec::new(),
};
for (idx, slot) in state.slots.iter().enumerate() {
if visible_set.contains(&idx) {
continue;
}
match slot.done {
None if slot.skipped => g.skipped.push(&slot.name),
None if in_queue.contains(&idx) => g.running.push(&slot.name),
None => g.pending.push(&slot.name),
Some(true) => g.done_ok.push(&slot.name),
Some(false) => g.done_err.push(&slot.name),
}
}
g
}
fn count_nonempty_groups(g: &OverflowGroups<'_>) -> usize {
[&g.running, &g.pending, &g.skipped, &g.done_ok, &g.done_err]
.iter()
.filter(|v| !v.is_empty())
.count()
}
fn render_overflow_lines(
f: &mut Frame,
areas: &[Rect],
width: usize,
groups: &OverflowGroups<'_>,
skip_reason: Option<&str>,
) {
let dim = Style::new().add_modifier(Modifier::DIM);
let cyan = Style::new().fg(Color::Cyan);
let yellow = Style::new().fg(Color::Yellow);
let green = Style::new().fg(Color::Green).add_modifier(Modifier::BOLD);
let red = Style::new().fg(Color::Red).add_modifier(Modifier::BOLD);
struct GroupSpec<'a> {
label: String,
names: &'a [&'a str],
label_style: Style,
name_style: Style,
}
let skipped_label = match skip_reason {
Some(reason) => format!("Skipped ({}): ", reason),
None => "Skipped: ".to_string(),
};
let specs = [
GroupSpec { label: "Running: ".into(), names: &groups.running, label_style: cyan, name_style: dim },
GroupSpec { label: "Pending: ".into(), names: &groups.pending, label_style: dim, name_style: dim },
GroupSpec { label: skipped_label, names: &groups.skipped, label_style: yellow, name_style: dim },
GroupSpec { label: "Done: ".into(), names: &groups.done_ok, label_style: green, name_style: green },
GroupSpec { label: "Failed: ".into(), names: &groups.done_err, label_style: red, name_style: red },
];
let mut area_idx = 0;
for spec in &specs {
if spec.names.is_empty() {
continue;
}
if area_idx >= areas.len() {
break;
}
let area = areas[area_idx];
area_idx += 1;
let prefix = " \u{00b7} "; let prefix_len = 4;
let label_len = spec.label.len();
let budget = width.saturating_sub(prefix_len + label_len);
let body = build_overflow_names(spec.names, budget);
let line = Line::from(vec![
Span::styled(prefix, dim),
Span::styled(spec.label.clone(), spec.label_style),
Span::styled(body, spec.name_style),
]);
f.render_widget(Paragraph::new(line), area);
}
}
pub(crate) fn build_overflow_names(names: &[&str], budget: usize) -> String {
let total = names.len();
if total == 0 || budget == 0 {
return String::new();
}
let names_len = |k: usize| -> usize {
names[..k]
.iter()
.enumerate()
.map(|(i, n)| if i == 0 { n.len() } else { 2 + n.len() })
.sum()
};
let text_len = |k: usize| -> usize {
let nl = names_len(k);
let rem = total - k;
nl + if rem == 0 { 0 } else { more_suffix(k, rem).len() }
};
let best_k = match (0..=total).rev().find(|&k| text_len(k) <= budget) {
Some(k) => k,
None => return String::new(),
};
let mut out = String::new();
for (i, &name) in names[..best_k].iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(name);
}
let rem = total - best_k;
if rem > 0 {
out.push_str(&more_suffix(best_k, rem));
}
out
}
fn more_suffix(first_shown: usize, count: usize) -> String {
if first_shown == 0 {
format!("and {} more", count)
} else {
format!(" and {} more", count)
}
}
fn parse_ansi_line(s: &str) -> Line<'static> {
match s.into_text() {
Ok(mut text) => text.lines.pop().unwrap_or_default(),
Err(_) => Line::from(strip_ansi_for_fallback(s).to_string()),
}
}
fn strip_ansi_for_fallback(s: &str) -> &str {
s
}
pub(crate) fn truncate_to_cols(s: &str, max: usize) -> String {
if max == 0 {
return "\x1b[0m".to_string();
}
let mut out = String::with_capacity(s.len() + 4);
let mut cols = 0usize;
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\x1b' {
if cols >= max {
break;
}
out.push(ch);
cols += 1;
continue;
}
match chars.peek().copied() {
Some('[') => {
chars.next(); let mut seq = String::from("\x1b[");
let mut final_byte = ' ';
for ec in chars.by_ref() {
seq.push(ec);
if ('\x40'..='\x7e').contains(&ec) {
final_byte = ec;
break;
}
}
if final_byte == 'm' {
out.push_str(&seq);
}
}
Some(']') => {
chars.next(); loop {
match chars.next() {
None | Some('\x07') => break,
Some('\x1b') => {
if chars.peek() == Some(&'\\') {
chars.next();
}
break;
}
Some(_) => {}
}
}
}
Some(_) => {
chars.next(); loop {
match chars.next() {
None | Some('\x07') => break,
Some('\x1b') => {
if chars.peek() == Some(&'\\') {
chars.next();
}
break;
}
Some(ec) if ec.is_ascii_alphabetic() => break,
Some(_) => {}
}
}
}
None => {}
}
}
out.push_str("\x1b[0m");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layout_all_fit_evenly() {
assert_eq!(tui_layout(3, 40), (3, 13));
}
#[test]
fn layout_overflow_visible_4() {
assert_eq!(tui_layout(5, 40), (4, 9));
}
#[test]
fn layout_exact_fit_min_height() {
assert_eq!(tui_layout(3, 27), (3, 9));
}
#[test]
fn layout_terminal_too_small_fallback() {
assert_eq!(tui_layout(2, 8), (0, 9));
}
#[test]
fn layout_single_member_fits() {
assert_eq!(tui_layout(1, 20), (1, 20));
}
#[test]
fn layout_zero_term_height() {
assert_eq!(tui_layout(3, 0), (0, MIN_PANE_HEIGHT));
}
#[test]
fn layout_zero_members() {
assert_eq!(tui_layout(0, 40), (0, MIN_PANE_HEIGHT));
}
fn empty_state(n: usize, visible: usize) -> RenderState {
RenderState {
slots: (0..n)
.map(|i| SlotData {
name: format!("m{i}"),
done: None,
skipped: false,
ring: VecDeque::new(),
})
.collect(),
pane_to_slot: Vec::new(),
background_queue: VecDeque::new(),
visible,
skip_reason: None,
}
}
#[test]
fn started_jobs_fill_panes_then_queue() {
let mut st = empty_state(4, 2);
note_started(&mut st, 0);
note_started(&mut st, 1);
assert_eq!(st.pane_to_slot, vec![0, 1]);
assert!(st.background_queue.is_empty());
note_started(&mut st, 2);
assert_eq!(st.pane_to_slot, vec![0, 1]);
assert_eq!(st.background_queue, VecDeque::from(vec![2]));
}
#[test]
fn unstarted_slots_are_pending_not_empty_panes() {
let mut st = empty_state(3, 2);
note_started(&mut st, 0);
assert_eq!(st.pane_to_slot, vec![0]);
let groups = classify_overflow_slots(&st);
assert_eq!(groups.pending, vec!["m1", "m2"]);
assert!(groups.running.is_empty());
}
#[test]
fn queued_started_slot_is_running_overflow() {
let mut st = empty_state(3, 1);
note_started(&mut st, 0);
note_started(&mut st, 1);
let groups = classify_overflow_slots(&st);
assert_eq!(groups.running, vec!["m1"]);
assert_eq!(groups.pending, vec!["m2"]);
}
#[test]
fn pane_reopens_after_close_for_later_starter() {
let mut st = empty_state(4, 2);
note_started(&mut st, 0);
note_started(&mut st, 1);
st.pane_to_slot.remove(0); note_started(&mut st, 2);
assert_eq!(st.pane_to_slot, vec![1, 2]);
assert!(st.background_queue.is_empty());
}
fn finish_background(st: &mut RenderState, slot_idx: usize, success: bool) {
st.slots[slot_idx].done = Some(success);
st.background_queue.retain(|&s| s != slot_idx);
if !success {
place_failed_in_pane(st, slot_idx);
}
}
#[test]
fn failed_background_takes_over_running_pane() {
let mut st = empty_state(2, 1);
note_started(&mut st, 0);
note_started(&mut st, 1);
assert_eq!(st.pane_to_slot, vec![0]);
assert_eq!(st.background_queue, VecDeque::from(vec![1]));
finish_background(&mut st, 1, false);
assert_eq!(st.pane_to_slot, vec![1]);
assert_eq!(st.background_queue, VecDeque::from(vec![0]));
}
#[test]
fn failed_background_uses_free_pane_without_demoting() {
let mut st = empty_state(2, 2);
note_started(&mut st, 0);
st.background_queue.push_back(1);
finish_background(&mut st, 1, false);
assert_eq!(st.pane_to_slot, vec![0, 1]);
assert!(st.background_queue.is_empty());
}
#[test]
fn failed_background_picks_first_running_pane() {
let mut st = empty_state(3, 2);
note_started(&mut st, 0);
note_started(&mut st, 1);
st.slots[0].done = Some(false); st.background_queue.push_back(2);
finish_background(&mut st, 2, false);
assert_eq!(st.pane_to_slot, vec![0, 2]); assert_eq!(st.background_queue, VecDeque::from(vec![1]));
}
#[test]
fn failed_background_stays_in_overflow_when_no_pane_is_running() {
let mut st = empty_state(2, 1);
note_started(&mut st, 0);
st.slots[0].done = Some(false);
st.background_queue.push_back(1);
finish_background(&mut st, 1, false);
assert_eq!(st.pane_to_slot, vec![0]); assert!(st.background_queue.is_empty());
let groups = classify_overflow_slots(&st);
assert_eq!(groups.done_err, vec!["m1"]);
}
#[test]
fn successful_background_does_not_take_a_pane() {
let mut st = empty_state(2, 1);
note_started(&mut st, 0);
note_started(&mut st, 1);
finish_background(&mut st, 1, true);
assert_eq!(st.pane_to_slot, vec![0]);
assert!(st.background_queue.is_empty());
let groups = classify_overflow_slots(&st);
assert_eq!(groups.done_ok, vec!["m1"]);
}
#[test]
fn shutdown_closes_successful_pane_keeps_failed() {
let mut st = empty_state(3, 2);
note_started(&mut st, 0);
note_started(&mut st, 1);
st.slots[0].done = Some(true); st.slots[1].done = Some(false);
apply_shutdown(&mut st);
assert_eq!(st.pane_to_slot, vec![1]); assert!(st.slots[2].skipped); }
#[test]
fn shutdown_marks_unstarted_jobs_skipped() {
let mut st = empty_state(2, 1);
note_started(&mut st, 0);
st.slots[0].done = Some(false);
apply_shutdown(&mut st);
let groups = classify_overflow_slots(&st);
assert_eq!(groups.skipped, vec!["m1"]);
assert!(groups.pending.is_empty());
}
#[test]
fn tail_is_empty_when_no_failures() {
let st = empty_state(3, 3);
let mut buf = Vec::<u8>::new();
print_failed_tails(&st, 15, &mut buf);
assert!(buf.is_empty());
}
#[test]
fn tail_skips_successful_and_pending_slots() {
let mut st = empty_state(3, 3);
st.slots[0].done = Some(true); st.slots[1].done = None; st.slots[2].done = Some(false); st.slots[2].ring.push_back("err line".to_string());
let mut buf = Vec::<u8>::new();
print_failed_tails(&st, 15, &mut buf);
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("m2"), "header for the failed slot");
assert!(out.contains("err line"));
assert!(!out.contains("m0"), "successful slot must not appear");
assert!(!out.contains("m1"), "pending slot must not appear");
}
#[test]
fn tail_prints_last_n_lines_and_trims_older_ones() {
let mut st = empty_state(1, 1);
st.slots[0].done = Some(false);
for i in 0..20u32 {
st.slots[0].ring.push_back(format!("line {i}"));
}
let mut buf = Vec::<u8>::new();
print_failed_tails(&st, 15, &mut buf);
let out = String::from_utf8(buf).unwrap();
for i in 5..20u32 {
assert!(out.contains(&format!("line {i}")), "missing line {i}");
}
for i in 0..5u32 {
assert!(!out.contains(&format!("line {i}\n")), "old line {i} must be trimmed");
}
}
#[test]
fn tail_prints_all_lines_when_fewer_than_max() {
let mut st = empty_state(1, 1);
st.slots[0].done = Some(false);
for i in 0..5u32 {
st.slots[0].ring.push_back(format!("e{i}"));
}
let mut buf = Vec::<u8>::new();
print_failed_tails(&st, 15, &mut buf);
let out = String::from_utf8(buf).unwrap();
for i in 0..5u32 {
assert!(out.contains(&format!("e{i}")));
}
}
#[test]
fn tail_prints_multiple_failed_slots_in_order() {
let mut st = empty_state(3, 3);
st.slots[0].done = Some(false);
st.slots[0].ring.push_back("alpha error".to_string());
st.slots[1].done = Some(true); st.slots[2].done = Some(false);
st.slots[2].ring.push_back("beta error".to_string());
let mut buf = Vec::<u8>::new();
print_failed_tails(&st, 15, &mut buf);
let out = String::from_utf8(buf).unwrap();
let pos_alpha = out.find("alpha error").unwrap();
let pos_beta = out.find("beta error").unwrap();
assert!(pos_alpha < pos_beta, "slot 0 must appear before slot 2");
}
#[test]
fn summary_empty_when_all_pending() {
let st = empty_state(3, 3);
let mut buf = Vec::<u8>::new();
print_build_summary(&st, &mut buf);
assert!(buf.is_empty());
}
#[test]
fn summary_prints_done_group() {
let mut st = empty_state(2, 2);
st.slots[0].done = Some(true);
st.slots[1].done = Some(true);
let mut buf = Vec::<u8>::new();
print_build_summary(&st, &mut buf);
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("Done:"), "Done label missing");
assert!(out.contains("m0"), "first member missing");
assert!(out.contains("m1"), "second member missing");
}
#[test]
fn summary_prints_failed_group() {
let mut st = empty_state(2, 2);
st.slots[0].done = Some(false);
st.slots[1].done = Some(false);
let mut buf = Vec::<u8>::new();
print_build_summary(&st, &mut buf);
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("Failed:"), "Failed label missing");
assert!(out.contains("m0"));
assert!(out.contains("m1"));
}
#[test]
fn summary_prints_skipped_group_with_reason() {
let mut st = empty_state(3, 1);
note_started(&mut st, 0);
st.slots[0].done = Some(false);
note_skipped(&mut st, 1, "core failed".to_string());
note_skipped(&mut st, 2, "core failed".to_string());
let mut buf = Vec::<u8>::new();
print_build_summary(&st, &mut buf);
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("Skipped (core failed):"), "skipped label with reason missing");
assert!(out.contains("m1"));
assert!(out.contains("m2"));
}
#[test]
fn summary_omits_empty_groups() {
let mut st = empty_state(2, 2);
st.slots[0].done = Some(true);
st.slots[1].done = Some(true);
let mut buf = Vec::<u8>::new();
print_build_summary(&st, &mut buf);
let out = String::from_utf8(buf).unwrap();
assert!(!out.contains("Failed:"), "empty Failed group must not appear");
assert!(!out.contains("Skipped:"), "empty Skipped group must not appear");
}
#[test]
fn summary_all_three_groups_in_order() {
let mut st = empty_state(3, 1);
note_started(&mut st, 0);
st.slots[0].done = Some(false);
note_skipped(&mut st, 1, "core failed".to_string());
st.slots[2].done = Some(true);
let mut buf = Vec::<u8>::new();
print_build_summary(&st, &mut buf);
let out = String::from_utf8(buf).unwrap();
let pos_skipped = out.find("Skipped").unwrap();
let pos_done = out.find("Done:").unwrap();
let pos_failed = out.find("Failed:").unwrap();
assert!(pos_skipped < pos_done, "Skipped must appear before Done");
assert!(pos_done < pos_failed, "Done must appear before Failed");
}
#[test]
fn drain_available_does_not_drop_slot_done_when_pending_already_set() {
use std::collections::VecDeque;
use std::sync::mpsc;
let (tx, rx) = mpsc::sync_channel::<TuiMsg>(10);
tx.send(TuiMsg::SlotDone { slot_idx: 0, success: true }).unwrap();
drop(tx);
let mut state = empty_state(2, 2);
let mut pending: VecDeque<TuiMsg> =
VecDeque::from([TuiMsg::SlotDone { slot_idx: 1, success: true }]);
drain_available(&rx, &mut state, &mut pending);
assert_eq!(pending.len(), 2, "drain_available must not drop SD_0");
}
fn slot_done_queue(pairs: &[(usize, bool)]) -> VecDeque<TuiMsg> {
pairs.iter().map(|&(s, ok)| TuiMsg::SlotDone { slot_idx: s, success: ok }).collect()
}
#[test]
fn resolve_flushes_slot_done_from_pending() {
let (tx, rx) = mpsc::sync_channel::<TuiMsg>(4);
drop(tx);
let mut state = empty_state(2, 2);
let pending = slot_done_queue(&[(0, true), (1, false)]);
resolve_outstanding_done_messages(&rx, &mut state, &pending);
assert_eq!(state.slots[0].done, Some(true));
assert_eq!(state.slots[1].done, Some(false));
}
#[test]
fn resolve_drains_slot_done_from_channel() {
let (tx, rx) = mpsc::sync_channel::<TuiMsg>(4);
tx.send(TuiMsg::SlotDone { slot_idx: 0, success: true }).unwrap();
tx.send(TuiMsg::SlotDone { slot_idx: 1, success: false }).unwrap();
drop(tx);
let mut state = empty_state(2, 2);
let pending = VecDeque::new();
resolve_outstanding_done_messages(&rx, &mut state, &pending);
assert_eq!(state.slots[0].done, Some(true));
assert_eq!(state.slots[1].done, Some(false));
}
#[test]
fn resolve_combines_pending_and_channel() {
let (tx, rx) = mpsc::sync_channel::<TuiMsg>(4);
tx.send(TuiMsg::SlotDone { slot_idx: 1, success: false }).unwrap();
drop(tx);
let mut state = empty_state(2, 2);
let pending = slot_done_queue(&[(0, true)]);
resolve_outstanding_done_messages(&rx, &mut state, &pending);
assert_eq!(state.slots[0].done, Some(true));
assert_eq!(state.slots[1].done, Some(false));
}
#[test]
fn resolve_does_not_overwrite_already_done_slot() {
let (tx, rx) = mpsc::sync_channel::<TuiMsg>(4);
drop(tx);
let mut state = empty_state(1, 1);
state.slots[0].done = Some(true);
let pending = slot_done_queue(&[(0, false)]);
resolve_outstanding_done_messages(&rx, &mut state, &pending);
assert_eq!(state.slots[0].done, Some(true), "first result must not be overwritten");
}
#[test]
fn resolve_ignores_non_slot_done_messages_in_channel() {
let (tx, rx) = mpsc::sync_channel::<TuiMsg>(4);
tx.send(TuiMsg::Line { slot_idx: 0, line: "hello".to_string() }).unwrap();
drop(tx);
let mut state = empty_state(1, 1);
let pending = VecDeque::new();
resolve_outstanding_done_messages(&rx, &mut state, &pending);
assert_eq!(state.slots[0].done, None, "non-SlotDone must be ignored");
}
#[test]
fn skipped_slots_are_separated_from_pending() {
let mut st = empty_state(3, 1);
note_started(&mut st, 0);
st.slots[1].skipped = true;
let groups = classify_overflow_slots(&st);
assert_eq!(groups.skipped, vec!["m1"]);
assert_eq!(groups.pending, vec!["m2"]);
assert!(groups.running.is_empty());
}
#[test]
fn note_skipped_marks_slots_and_keeps_first_reason() {
let mut st = empty_state(3, 1);
note_started(&mut st, 0);
note_skipped(&mut st, 1, "lombok-greeter failed".to_string());
note_skipped(&mut st, 2, "something-else failed".to_string());
assert!(st.slots[1].skipped);
assert!(st.slots[2].skipped);
assert_eq!(st.skip_reason.as_deref(), Some("lombok-greeter failed"));
let groups = classify_overflow_slots(&st);
assert_eq!(groups.skipped, vec!["m1", "m2"]);
}
#[test]
fn skipped_takes_precedence_over_queued_running() {
let mut st = empty_state(2, 1);
note_started(&mut st, 0);
st.background_queue.push_back(1);
st.slots[1].skipped = true;
let groups = classify_overflow_slots(&st);
assert_eq!(groups.skipped, vec!["m1"]);
assert!(groups.running.is_empty());
assert!(groups.pending.is_empty());
}
#[test]
fn heights_divide_evenly() {
assert_eq!(distribute_pane_heights(40, 4), vec![10, 10, 10, 10]);
}
#[test]
fn heights_remainder_goes_to_top_panes() {
assert_eq!(distribute_pane_heights(41, 4), vec![11, 10, 10, 10]);
assert_eq!(distribute_pane_heights(38, 4), vec![10, 10, 9, 9]);
}
#[test]
fn heights_survivors_grow_when_a_pane_closes() {
assert_eq!(distribute_pane_heights(39, 3), vec![13, 13, 13]);
assert_eq!(distribute_pane_heights(39, 2), vec![20, 19]);
assert_eq!(distribute_pane_heights(39, 1), vec![39]);
}
#[test]
fn heights_no_panes_is_empty() {
assert_eq!(distribute_pane_heights(40, 0), Vec::<u16>::new());
}
#[test]
fn overflow_all_fit() {
let names = ["alpha", "beta", "gamma"];
assert_eq!(build_overflow_names(&names, 40), "alpha, beta, gamma");
}
#[test]
fn overflow_longest_fitting_prefix_shown() {
let names = ["alpha", "beta-long", "gamma"];
assert_eq!(build_overflow_names(&names, 18), "alpha and 2 more");
}
#[test]
fn overflow_first_name_too_long_shows_and_more() {
let names = ["very-long-name"];
assert_eq!(build_overflow_names(&names, 10), "and 1 more");
}
#[test]
fn overflow_nothing_fits_returns_empty() {
let names = ["a", "b"];
assert_eq!(build_overflow_names(&names, 3), "");
}
#[test]
fn overflow_single_name_fits() {
assert_eq!(build_overflow_names(&["hello"], 20), "hello");
}
#[test]
fn overflow_single_name_too_long_and_suffix_also_too_long_returns_empty() {
let names = ["hello-world"];
assert_eq!(build_overflow_names(&names, 5), "");
}
#[test]
fn overflow_empty_names() {
assert_eq!(build_overflow_names(&[], 40), "");
}
#[test]
fn overflow_two_names_both_fit() {
let names = ["a", "b"];
assert_eq!(build_overflow_names(&names, 10), "a, b");
}
#[test]
fn overflow_and_more_standalone_when_no_names_fit() {
let names = ["alpha", "beta", "gamma", "delta"];
assert_eq!(build_overflow_names(&names, 12), "and 4 more");
}
#[test]
fn overflow_all_names_fit_no_suffix() {
let names = ["a", "b", "c", "d"];
assert_eq!(build_overflow_names(&names, 15), "a, b, c, d");
}
#[test]
fn truncate_plain_text_at_max() {
assert_eq!(truncate_to_cols("hello world", 5), "hello\x1b[0m");
}
#[test]
fn truncate_plain_text_fits() {
assert_eq!(truncate_to_cols("hi", 10), "hi\x1b[0m");
}
#[test]
fn truncate_exact_length() {
assert_eq!(truncate_to_cols("abc", 3), "abc\x1b[0m");
}
#[test]
fn truncate_zero_max_returns_only_reset() {
assert_eq!(truncate_to_cols("anything", 0), "\x1b[0m");
}
#[test]
fn truncate_ansi_sequences_dont_count_columns() {
let input = "\x1b[32mhello\x1b[0m world";
let result = truncate_to_cols(input, 7);
assert_eq!(result, "\x1b[32mhello\x1b[0m w\x1b[0m");
}
#[test]
fn truncate_ansi_at_start_no_visible_chars() {
let input = "\x1b[1m";
assert_eq!(truncate_to_cols(input, 5), "\x1b[1m\x1b[0m");
}
#[test]
fn truncate_non_sgr_csi_is_dropped() {
let input = "\x1b[1Ahello";
assert_eq!(truncate_to_cols(input, 10), "hello\x1b[0m");
}
#[test]
fn truncate_osc8_hyperlink_bel_terminated_is_dropped() {
let input = "\x1b]8;;https://example.com\x07click\x1b]8;;\x07";
assert_eq!(truncate_to_cols(input, 20), "click\x1b[0m");
}
#[test]
fn truncate_osc8_hyperlink_st_terminated_is_dropped() {
let input = "\x1b]8;;https://example.com\x1b\\click\x1b]8;;\x1b\\";
assert_eq!(truncate_to_cols(input, 20), "click\x1b[0m");
}
#[test]
fn truncate_osc_mid_line_strips_only_osc() {
let input = "\x1b[33mfile: \x1b]8;;file:///tmp/F.java\x07F.java\x1b]8;;\x07\x1b[0m";
assert_eq!(truncate_to_cols(input, 40), "\x1b[33mfile: F.java\x1b[0m\x1b[0m");
}
#[test]
fn truncate_empty_string() {
assert_eq!(truncate_to_cols("", 10), "\x1b[0m");
}
}