use ratatui::{
Frame,
layout::{Constraint, Flex, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Clear, Paragraph, Wrap},
};
#[cfg(test)]
use ratatui::widgets::{Block, BorderType, Borders};
use crate::app::{JobStage, JobState, PopupState};
use crate::palette;
use ratatui_glamour::color::{blend_1d, blend_2d};
use ratatui_glamour::surface::render_gradient_rounded_panel;
#[cfg(test)]
use crate::runner::BuildPlan;
enum TileLines<'a> {
Borrowed(&'a [Line<'static>]),
#[cfg(test)]
Owned(Vec<Line<'static>>),
}
impl<'a> TileLines<'a> {
fn as_slice(&self) -> &[Line<'static>] {
match self {
Self::Borrowed(lines) => lines,
#[cfg(test)]
Self::Owned(lines) => lines,
}
}
}
pub struct BuildScreen<'a> {
tiles: Vec<BuildTile<'a>>,
popup: Option<FinishPopup>,
label: Option<&'a str>,
}
impl<'a> BuildScreen<'a> {
#[cfg(test)]
pub fn from_plan(plan: &BuildPlan) -> Self {
Self {
tiles: plan.jobs().iter().map(BuildTile::from_job).collect(),
popup: None,
label: None,
}
}
pub fn from_states(
states: &'a [JobState],
_active_pane: usize,
scrollbacks: &[usize],
popup: Option<PopupState>,
wrap_lines: bool,
label: Option<&'a str>,
excuse: Option<&'a str>,
) -> Self {
let any_failed = states.iter().any(JobState::is_failed);
Self {
tiles: states
.iter()
.enumerate()
.map(|(index, state)| {
BuildTile::from_state(
state,
*scrollbacks.get(index).unwrap_or(&0),
wrap_lines,
)
})
.collect(),
popup: popup.map(|p| FinishPopup::from_state(p, any_failed, excuse)),
label,
}
}
pub fn render(&self, frame: &mut Frame<'_>) {
TileGrid::new(self.tiles.len())
.split(frame.area())
.iter()
.zip(self.tiles.iter())
.for_each(|(area, tile)| tile.render(frame, *area));
self.render_label(frame);
self.popup.iter().for_each(|popup| popup.render(frame));
}
fn render_label(&self, frame: &mut Frame<'_>) {
if let Some(label) = self.label {
let area = frame.area();
let any_failed = self
.tiles
.iter()
.any(|t| matches!(t.status.stage(), JobStage::Failed));
let fg = if any_failed {
palette::FG
} else {
palette::WARNING_HEAT
};
let styled_label = format!(" {} ", label);
let label_width = styled_label.len() as u16;
let x = area.x + area.width.saturating_sub(label_width + 3);
frame.buffer_mut().set_line(
x,
area.y,
&Line::from(Span::styled(styled_label, Style::default().fg(fg))),
label_width,
);
}
}
pub fn viewport_height(count: usize, active_pane: usize, area: Rect) -> usize {
TileGrid::new(count)
.split(area)
.get(active_pane)
.map(|tile_area| tile_area.height.saturating_sub(2) as usize)
.unwrap_or(1)
}
#[cfg(test)]
pub fn tiles(&self) -> &[BuildTile<'a>] {
&self.tiles
}
}
pub struct BuildTile<'a> {
status: TileStatus,
viewport: TileViewport<'a>,
}
impl<'a> BuildTile<'a> {
#[cfg(test)]
pub fn from_job(job: &crate::runner::BuildJob) -> Self {
Self {
status: TileStatus::from_job(job),
viewport: TileViewport::empty(),
}
}
pub fn from_state(
state: &'a JobState,
scrollback: usize,
wrap_lines: bool,
) -> Self {
Self {
status: TileStatus::from_state(state),
viewport: TileViewport::from_lines(state.log_lines(), scrollback, wrap_lines),
}
}
pub fn render(&self, frame: &mut Frame<'_>, area: Rect) {
frame.render_widget(Clear, area);
let stops: &[Color] = if matches!(self.status.stage(), JobStage::Failed) {
&[palette::ERROR]
} else {
&[
Color::Indexed(205),
Color::Indexed(99),
Color::Indexed(51),
Color::Indexed(99),
Color::Indexed(205),
]
};
let inner =
render_gradient_rounded_panel(frame.buffer_mut(), area, Style::default(), stops);
self.viewport.render_content(frame, inner);
self.status.render_title(frame, area);
}
#[cfg(test)]
pub fn status(&self) -> &TileStatus {
&self.status
}
}
pub struct TileStatus {
os: String,
platform: String,
hostname: String,
summary: String,
load_info: String,
stage: JobStage,
}
fn run_m() -> Color {
Color::Rgb(175, 0, 175)
}
fn run_a() -> Color {
Color::Rgb(175, 0, 255)
}
fn run_b() -> Color {
Color::Rgb(175, 95, 255)
}
fn m_color(stage: JobStage) -> Color {
match stage {
JobStage::Failed => palette::ERROR_GLOW,
JobStage::Success => palette::SUCCESS_GLOW,
_ => run_m(),
}
}
fn a_color(stage: JobStage) -> Color {
match stage {
JobStage::Failed => palette::ERROR_HEAT,
JobStage::Success => palette::SUCCESS_HEAT,
_ => run_a(),
}
}
fn b_color(stage: JobStage) -> Color {
match stage {
JobStage::Failed => palette::ERROR_PEAK,
JobStage::Success => palette::SUCCESS_PEAK,
_ => run_b(),
}
}
fn s_color(stage: JobStage) -> Color {
match stage {
JobStage::Success => palette::SUCCESS,
JobStage::Failed => palette::ERROR,
_ => run_m(),
}
}
impl TileStatus {
#[cfg(test)]
pub fn from_job(job: &crate::runner::BuildJob) -> Self {
let target = job.target();
Self {
os: target.os().to_string(),
platform: target.arch().to_string(),
hostname: target.host().to_string(),
summary: "pending".to_string(),
load_info: String::new(),
stage: JobStage::Pending,
}
}
pub fn from_state(state: &JobState) -> Self {
let mut parts = state.title().splitn(3, ' ');
let os = parts.next().unwrap_or("").to_string();
let platform = parts.next().unwrap_or("").to_string();
let destination = parts.next().unwrap_or("");
let hostname = destination
.split(':')
.next()
.unwrap_or(destination)
.to_string();
Self {
os,
platform,
hostname,
summary: state.summary().to_string(),
load_info: state.load_info().to_string(),
stage: state.stage(),
}
}
#[cfg(test)]
pub fn from_fixture(os: &str, platform: &str, hostname: &str, stage: JobStage) -> Self {
Self {
os: os.to_string(),
platform: platform.to_string(),
hostname: hostname.to_string(),
summary: stage.label().to_string(),
load_info: String::new(),
stage,
}
}
#[cfg(test)]
fn border_color(&self, active: bool) -> Color {
if matches!(self.stage, JobStage::Failed) {
palette::ERROR_GLOW
} else if matches!(
self.stage,
JobStage::Pending | JobStage::Building | JobStage::Mirroring
) {
run_m()
} else if active {
palette::SUCCESS_GLOW
} else {
Color::Reset
}
}
#[cfg(test)]
pub fn render(&self, frame: &mut Frame<'_>, area: Rect, active: bool) {
frame.render_widget(Clear, area);
let g = m_color(self.stage);
let a = a_color(self.stage);
let b = b_color(self.stage);
let s = s_color(self.stage);
let border = self.border_color(active);
let border_style = Style::default().fg(border);
let black = Style::default()
.fg(Color::Black)
.add_modifier(ratatui::style::Modifier::BOLD);
let white = Style::default()
.fg(Color::White)
.add_modifier(ratatui::style::Modifier::BOLD);
let os_text = format!(" {} ", self.os);
let platform_text = format!(" {} ", self.platform);
let host_text = format!(" {} ", self.hostname);
let stage_text = format!(" {} ", self.summary);
let load_width = if self.load_info.is_empty() {
0
} else {
3 + self.load_info.len() + 2
};
let title_width = 5
+ os_text.len()
+ platform_text.len()
+ host_text.len()
+ stage_text.len()
+ load_width;
let fill = (area.width as usize).saturating_sub(2 + title_width);
let left_fill = fill / 2;
let right_fill = fill - left_fill;
let is_running = matches!(
self.stage,
JobStage::Pending | JobStage::Building | JobStage::Mirroring
);
let failed = matches!(self.stage, JobStage::Failed);
let stage_fg = if is_running || failed { white } else { black };
let host_fg = if failed { white } else { black };
let mut status_spans: Vec<Span> = vec![
Span::styled("â•°", border_style),
Span::styled("─".repeat(left_fill), border_style),
Span::styled("\u{E0B2}", Style::default().fg(g)),
Span::styled(os_text, black.bg(g)),
Span::styled("\u{E0B2}", Style::default().fg(a).bg(g)),
Span::styled(platform_text, black.bg(a)),
Span::styled("\u{E0B2}", Style::default().fg(b).bg(a)),
Span::styled(host_text, host_fg.bg(b)),
Span::styled("\u{E0B2}", Style::default().fg(s).bg(b)),
Span::styled(stage_text, stage_fg.bg(s)),
];
if !self.load_info.is_empty() {
status_spans.push(Span::styled(" \u{2726} ", white.bg(s)));
status_spans.push(Span::styled(format!(" {} ", self.load_info), white.bg(s)));
}
status_spans.push(Span::styled("\u{E0B0}", Style::default().fg(s)));
status_spans.push(Span::styled("─".repeat(right_fill), border_style));
status_spans.push(Span::styled("╯", border_style));
frame.render_widget(Paragraph::new(Line::from(status_spans)), area);
}
fn render_title(&self, frame: &mut Frame<'_>, area: Rect) {
let g = m_color(self.stage);
let a = a_color(self.stage);
let b = b_color(self.stage);
let s = s_color(self.stage);
let black = Style::default()
.fg(Color::Black)
.add_modifier(ratatui::style::Modifier::BOLD);
let white = Style::default()
.fg(Color::White)
.add_modifier(ratatui::style::Modifier::BOLD);
let is_running = matches!(
self.stage,
JobStage::Pending | JobStage::Building | JobStage::Mirroring
);
let failed = matches!(self.stage, JobStage::Failed);
let stage_fg = if is_running || failed { white } else { black };
let host_fg = if failed { white } else { black };
let os_text = format!(" {} ", self.os);
let platform_text = format!(" {} ", self.platform);
let host_text = format!(" {} ", self.hostname);
let stage_text = format!(" {} ", self.summary);
let load_width = if self.load_info.is_empty() {
0
} else {
3 + self.load_info.len() + 2
};
let title_width = 5
+ os_text.len()
+ platform_text.len()
+ host_text.len()
+ stage_text.len()
+ load_width;
let mut title_spans: Vec<Span> = vec![
Span::styled("\u{E0B2}", Style::default().fg(g)),
Span::styled(os_text, black.bg(g)),
Span::styled("\u{E0B2}", Style::default().fg(a).bg(g)),
Span::styled(platform_text, black.bg(a)),
Span::styled("\u{E0B2}", Style::default().fg(b).bg(a)),
Span::styled(host_text, host_fg.bg(b)),
Span::styled("\u{E0B2}", Style::default().fg(s).bg(b)),
Span::styled(stage_text, stage_fg.bg(s)),
];
if !self.load_info.is_empty() {
title_spans.push(Span::styled(" \u{2726} ", white.bg(s)));
title_spans.push(Span::styled(format!(" {} ", self.load_info), white.bg(s)));
}
title_spans.push(Span::styled("\u{E0B0}", Style::default().fg(s)));
let tw = title_width as u16;
let center_x = area.x + area.width.saturating_sub(tw) / 2;
let bottom_y = area.y + area.height.saturating_sub(1);
let title_rect = Rect::new(center_x, bottom_y, tw.min(area.width), 1);
frame.render_widget(Paragraph::new(Line::from(title_spans)), title_rect);
}
#[cfg(test)]
pub fn title(&self) -> String {
format!("{} {} {}", self.os, self.platform, self.hostname)
}
pub fn stage(&self) -> JobStage {
self.stage
}
}
pub struct TileViewport<'a> {
lines: TileLines<'a>,
scrollback: usize,
wrap_lines: bool,
}
#[cfg(test)]
impl TileViewport<'static> {
pub fn empty() -> Self {
Self {
lines: TileLines::Owned(Vec::new()),
scrollback: 0,
wrap_lines: false,
}
}
pub fn from_ansi(source: &str, scrollback: usize) -> Self {
Self::from_owned_lines(
crate::ansi::AnsiDocument::parse(source).lines(),
scrollback,
false,
)
}
fn from_owned_lines(lines: Vec<Line<'static>>, scrollback: usize, wrap_lines: bool) -> Self {
Self {
lines: TileLines::Owned(lines),
scrollback,
wrap_lines,
}
}
}
impl<'a> TileViewport<'a> {
pub fn from_lines(lines: &'a [Line<'static>], scrollback: usize, wrap_lines: bool) -> Self {
Self {
lines: TileLines::Borrowed(lines),
scrollback,
wrap_lines,
}
}
#[cfg(test)]
pub fn render(&self, frame: &mut Frame<'_>, area: Rect, active: bool, stage: JobStage) {
let block = Block::default()
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded)
.border_style(self.border_style(active, stage));
let inner = block.inner(area);
let visible_lines = self.visible_lines(inner);
frame.render_widget(block, area);
if self.wrap_lines {
frame.render_widget(
Paragraph::new(visible_lines).wrap(Wrap { trim: false }),
inner,
);
} else {
frame.render_widget(Paragraph::new(visible_lines), inner);
}
}
fn render_content(&self, frame: &mut Frame<'_>, area: Rect) {
let visible_lines = self.visible_lines(area);
if self.wrap_lines {
frame.render_widget(
Paragraph::new(visible_lines).wrap(Wrap { trim: false }),
area,
);
} else {
frame.render_widget(Paragraph::new(visible_lines), area);
}
}
#[cfg(test)]
pub fn scroll_y(&self, line_count: usize, area: Rect) -> u16 {
line_count
.saturating_sub(self.visible_tail_start(area))
.try_into()
.unwrap_or(u16::MAX)
}
#[cfg(test)]
fn visible_tail_start(&self, area: Rect) -> usize {
self.inner_height(area).saturating_add(self.scrollback)
}
fn visible_lines(&self, area: Rect) -> Vec<Line<'static>> {
let lines = self.lines.as_slice();
let end = lines.len().saturating_sub(self.scrollback);
let start = end.saturating_sub(self.inner_height(area));
let width = area.width as usize;
lines[start..end]
.iter()
.map(|line| {
if self.wrap_lines {
line.clone()
} else {
Self::truncate_line(line, width)
}
})
.collect()
}
fn inner_height(&self, area: Rect) -> usize {
area.height as usize
}
fn truncate_line(line: &Line<'static>, width: usize) -> Line<'static> {
if width == 0 {
return Line::default();
}
let visible_width = Self::line_width(line);
if visible_width <= width {
return line.clone();
}
if width <= 3 {
return Line::from(vec![Span::raw(".".repeat(width))]);
}
let keep = width - 3;
let mut visible = 0usize;
let mut spans = Vec::new();
let mut ellipsis_style = Style::default();
for span in &line.spans {
let span_width = span.content.chars().count();
if visible + span_width <= keep {
if span_width > 0 {
ellipsis_style = span.style;
}
visible += span_width;
spans.push(span.clone());
continue;
}
let remaining = keep.saturating_sub(visible);
if remaining > 0 {
let text = span.content.chars().take(remaining).collect::<String>();
ellipsis_style = span.style;
spans.push(Span::styled(text, span.style));
}
break;
}
spans.push(Span::styled("...", ellipsis_style));
Line::from(spans)
}
fn line_width(line: &Line<'static>) -> usize {
line.spans
.iter()
.map(|span| span.content.chars().count())
.sum()
}
#[cfg(test)]
fn border_style(&self, active: bool, stage: JobStage) -> Style {
if matches!(stage, JobStage::Failed) {
return Style::default().fg(palette::ERROR_GLOW);
}
if matches!(
stage,
JobStage::Pending | JobStage::Building | JobStage::Mirroring
) {
return Style::default().fg(run_m());
}
if active {
Style::default().fg(palette::SUCCESS_GLOW)
} else {
Style::default()
}
}
}
#[cfg(test)]
pub struct TileLayout {
area: Rect,
}
#[cfg(test)]
impl TileLayout {
pub fn new(area: Rect) -> Self {
Self { area }
}
pub fn split(&self) -> SplitTileLayout {
Layout::vertical([Constraint::Min(1), Constraint::Length(1)])
.split(self.area)
.to_vec()
.pipe_ref(|chunks| SplitTileLayout::new(chunks[0], chunks[1]))
}
}
#[cfg(test)]
pub struct SplitTileLayout {
viewport: Rect,
status: Rect,
}
#[cfg(test)]
impl SplitTileLayout {
pub fn new(viewport: Rect, status: Rect) -> Self {
Self { viewport, status }
}
pub fn viewport(&self) -> Rect {
self.viewport
}
pub fn status(&self) -> Rect {
self.status
}
}
pub struct TileGrid {
count: usize,
}
impl TileGrid {
pub fn new(count: usize) -> Self {
Self { count }
}
pub fn split(&self, area: Rect) -> Vec<Rect> {
GridShape::from_count(self.count)
.rows(area)
.iter()
.flat_map(|row| GridShape::from_count(self.count).cols(*row))
.take(self.count)
.collect()
}
}
pub struct GridShape {
rows: usize,
cols: usize,
}
impl GridShape {
pub fn from_count(count: usize) -> Self {
((count.max(1) as f64).sqrt().ceil() as usize).pipe_ref(|cols| Self {
cols: *cols,
rows: count.max(1).div_ceil(*cols),
})
}
pub fn rows(&self, area: Rect) -> Vec<Rect> {
Layout::vertical(
(0..self.rows)
.map(|_| Constraint::Ratio(1, self.rows as u32))
.collect::<Vec<_>>(),
)
.split(area)
.to_vec()
}
pub fn cols(&self, area: Rect) -> Vec<Rect> {
Layout::horizontal(
(0..self.cols)
.map(|_| Constraint::Ratio(1, self.cols as u32))
.collect::<Vec<_>>(),
)
.split(area)
.to_vec()
}
}
fn rounded_perimeter(w: usize, h: usize) -> Vec<(u16, u16, &'static str)> {
let mut out = Vec::new();
if w == 0 || h == 0 {
return out;
}
let w = w as u16;
let h = h as u16;
out.push((0, 0, "\u{256D}"));
for x in 1..w.saturating_sub(1) {
out.push((x, 0, "\u{2500}"));
}
if w > 1 {
out.push((w - 1, 0, "\u{256E}"));
}
for y in 1..h.saturating_sub(1) {
out.push((w - 1, y, "\u{2502}"));
}
if w > 1 && h > 1 {
out.push((w - 1, h - 1, "\u{256F}"));
}
for x in (1..w.saturating_sub(1)).rev() {
out.push((x, h - 1, "\u{2500}"));
}
if h > 1 {
out.push((0, h - 1, "\u{2570}"));
}
for y in (1..h.saturating_sub(1)).rev() {
out.push((0, y, "\u{2502}"));
}
out
}
fn gradient_text_line(text: &str, stops: &[Color]) -> Line<'static> {
let colors = blend_1d(text.chars().count().max(1), stops);
Line::from(
text.chars()
.enumerate()
.map(|(idx, ch)| Span::styled(ch.to_string(), Style::default().fg(colors[idx])))
.collect::<Vec<_>>(),
)
}
pub struct FinishPopup {
lines: Vec<String>,
failed: bool,
}
impl FinishPopup {
pub fn from_state(state: PopupState, failed: bool, excuse: Option<&str>) -> Self {
let mut lines = match state {
PopupState::Finished => {
vec!["Press \"q\" to quit, any key to continue".to_string()]
}
PopupState::AbortConfirm => {
vec!["^C again or \"y\" to abort the running farm, any key to continue"
.to_string()]
}
};
if let Some(excuse) = excuse {
lines.insert(0, format!(" {}", excuse));
}
Self { lines, failed }
}
pub fn render(&self, frame: &mut Frame<'_>) {
let area = PopupLayout::new(frame.area(), self.width(), self.height()).area();
frame.render_widget(Clear, area);
let bg_stops: &[Color] = if self.failed {
&[palette::PROCESSING_BASE_DIMMED, palette::BG_2]
} else {
&[palette::BG_3, palette::BG_2]
};
let border_stops = &[
Color::Indexed(205),
Color::Indexed(99),
Color::Indexed(51),
Color::Indexed(99),
Color::Indexed(205),
];
let perimeter = rounded_perimeter(area.width as usize, area.height as usize);
let colors = blend_1d(perimeter.len().max(1), border_stops);
let buf = frame.buffer_mut();
for (idx, (x, y, sym)) in perimeter.into_iter().enumerate() {
if let Some(cell) = buf.cell_mut((area.x + x, area.y + y)) {
cell.set_symbol(sym);
cell.set_fg(colors[idx]);
}
}
let inner = Rect::new(
area.x + 1,
area.y + 1,
area.width.saturating_sub(2),
area.height.saturating_sub(2),
);
let gradient = blend_2d(
area.width as usize,
area.height as usize,
25.0,
bg_stops,
);
for y in 0..area.height {
for x in 0..area.width {
let idx = y as usize * area.width as usize + x as usize;
if let Some(cell) = buf.cell_mut((area.x + x, area.y + y)) {
cell.set_bg(gradient[idx]);
}
}
}
let exc_stops: &[Color] = &[
Color::Indexed(159),
Color::Indexed(153),
Color::Indexed(147),
Color::Indexed(141),
Color::Indexed(135),
Color::Indexed(129),
];
let quit_stops: &[Color] = &[
Color::Indexed(229),
Color::Indexed(221),
Color::Indexed(216),
Color::Indexed(210),
Color::Indexed(204),
];
let gaps = (inner.height as usize).saturating_sub(self.lines.len());
let gap_height = gaps / (self.lines.len() + 1);
for (i, line) in self.lines.iter().enumerate() {
let stops = if self.failed && i == 0 {
exc_stops
} else {
quit_stops
};
let text_line = gradient_text_line(line, stops);
let y = inner.y + gap_height as u16 + (1 + gap_height) as u16 * i as u16;
let text_width = line.chars().count() as u16;
let x = inner.x + inner.width.saturating_sub(text_width) / 2;
buf.set_line(x, y, &text_line, text_width);
}
}
fn width(&self) -> u16 {
self.lines
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(0)
.saturating_add(6)
.try_into()
.unwrap_or(u16::MAX)
}
fn height(&self) -> u16 {
(self.lines.len() * 2 + 3) as u16
}
}
struct PopupLayout {
area: Rect,
width: u16,
height: u16,
}
impl PopupLayout {
fn new(area: Rect, width: u16, height: u16) -> Self {
Self { area, width, height }
}
fn area(&self) -> Rect {
Layout::vertical([Constraint::Length(self.height)])
.flex(Flex::Center)
.split(self.area)
.to_vec()
.pipe_ref(|rows| {
Layout::horizontal([Constraint::Length(self.width(rows[0]))])
.flex(Flex::Center)
.split(rows[0])
.to_vec()[0]
})
}
fn width(&self, row: Rect) -> u16 {
row.width.min(self.width)
}
}
trait PipeRef: Sized {
fn pipe_ref<T>(self, f: impl FnOnce(&Self) -> T) -> T {
f(&self)
}
}
impl<T> PipeRef for T {}