use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app::App;
use crate::keymap::Action;
use crate::ui::Hint;
const HINTS: &[Hint] = &[
Hint::Leaf(&[Action::Open], "open graph"),
Hint::Leaf(&[Action::Search], "search"),
Hint::Leaf(&[Action::Help], "help"),
];
pub(super) fn draw(frame: &mut Frame<'_>, app: &mut App) {
let (body, bar) = super::screen_chrome(frame, app, HINTS);
draw_list(frame, app, body);
frame.render_widget(Paragraph::new(super::status_bar(app, bar.width)), bar);
}
fn clip_ranges(
ranges: &[(std::ops::Range<usize>, bool)],
start: usize,
len: usize,
) -> Vec<(std::ops::Range<usize>, bool)> {
ranges
.iter()
.filter_map(|(r, current)| {
let s = r.start.max(start);
let e = r.end.min(start + len);
(s < e).then(|| (s - start..e - start, *current))
})
.collect()
}
fn draw_list(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
if app.runs.is_empty() {
frame.render_widget(
Paragraph::new(Line::styled(" no runs yet…", app.theme.dim_style())),
area,
);
return;
}
let height = area.height.max(1) as usize;
let scroll =
super::scroll_to_cursor(app.runs_selected(), app.runs_scroll, height, app.runs.len());
app.runs_scroll = scroll;
let rows: Vec<Line<'static>> = app
.runs
.iter()
.enumerate()
.skip(scroll)
.take(height)
.map(|(i, run)| {
let selected = i == app.runs_selected();
let glyph = run.status.glyph();
let color = super::ci_status_color(&app.theme, run.status);
let short = run.commit.chars().take(7).collect::<String>();
let ranges = app
.search
.as_ref()
.map(|s| s.ranges_for(i))
.unwrap_or_default();
let name_txt = format!("{:<16}", super::elide(&run.name, 16));
let title_txt = format!("{:<32}", super::elide(&run.title, 32));
let mut spans = vec![
Span::raw(" "),
Span::styled(format!("{glyph} "), Style::new().fg(color)),
];
spans.extend(super::highlight_spans(
&name_txt,
Style::new().fg(app.theme.accent),
&clip_ranges(&ranges, 0, run.name.chars().count()),
&app.theme,
));
spans.push(Span::raw(" "));
spans.extend(super::highlight_spans(
&title_txt,
Style::new().fg(app.theme.fg),
&clip_ranges(
&ranges,
run.name.chars().count() + 1,
run.title.chars().count(),
),
&app.theme,
));
spans.extend([
Span::styled(
format!(" {:<14}", super::elide(&run.branch, 14)),
Style::new().fg(app.theme.purple),
),
Span::styled(format!(" {short}"), Style::new().fg(app.theme.warn_fg)),
]);
if let Some(created) = run.created {
let age = super::relative_time(app.now_unix, created.unix_timestamp());
let used: usize = spans.iter().map(ratatui::text::Span::width).sum();
if used + age.chars().count() + 1 < area.width as usize {
let gap = area.width as usize - used - age.chars().count() - 1;
spans.push(Span::raw(" ".repeat(gap)));
spans.push(Span::styled(age, app.theme.dim_style()));
}
}
let line = Line::from(spans);
if selected {
super::cursor_line(line, &app.theme, area.width)
} else {
line
}
})
.collect();
frame.render_widget(Paragraph::new(rows), area);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ci::{CiRun, JobStatus, RunId};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use crate::config::LoadedConfig;
use crate::test_support::standard_fixture;
fn run(name: &str, branch: &str, sha: &str, status: JobStatus) -> CiRun {
CiRun {
id: RunId(name.to_owned()),
name: name.to_owned(),
title: "fix the thing".to_owned(),
branch: branch.to_owned(),
commit: sha.to_owned(),
author: String::new(),
created: None,
status,
url: None,
remote: None,
}
}
#[test]
fn renders_the_runs_list_with_a_selection() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.runs = vec![
run("CI", "main", "abc1234def", JobStatus::Ok),
run("Release", "feature/x", "9988776655", JobStatus::Failed),
run("Nightly", "main", "0011223344", JobStatus::Running),
];
let backend = TestBackend::new(80, 8);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| draw(f, &mut app)).expect("draw");
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn the_selected_run_carries_the_cursor_band_across_the_row() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.runs = vec![
run("CI", "main", "abc1234def", JobStatus::Ok),
run("Release", "feature/x", "9988776655", JobStatus::Failed),
];
let backend = TestBackend::new(80, 8);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| draw(f, &mut app)).expect("draw");
let buffer = terminal.backend().buffer();
let banded = |y: u16| {
(0..80).all(|x| buffer.cell((x, y)).expect("in bounds").bg == app.theme.cursor_line)
};
assert!(banded(1), "the selected row bands edge to edge");
assert!(!banded(2), "its neighbour keeps the plain surface");
}
#[test]
fn renders_empty_runs() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
let backend = TestBackend::new(80, 6);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| draw(f, &mut app)).expect("draw");
insta::assert_snapshot!(terminal.backend());
}
}