use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::app::{Action, App};
use crate::model::Issue;
use crate::storage::Store;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueRow {
pub number: u32,
pub title: String,
pub status: String,
pub filename: String,
}
impl IssueRow {
pub fn from_issue(issue: &Issue) -> Self {
IssueRow {
number: issue.number,
title: issue.title.clone(),
status: issue.status.display().to_string(),
filename: issue.filename.clone(),
}
}
}
pub fn cycle_rows(store: &Store, name: &str) -> Vec<IssueRow> {
store
.list()
.unwrap_or_default()
.iter()
.filter(|issue| {
issue
.cycles
.as_ref()
.is_some_and(|cycles| cycles.iter().any(|c| c == name))
})
.map(IssueRow::from_issue)
.collect()
}
pub fn milestone_rows(store: &Store, name: &str) -> Vec<IssueRow> {
store
.list()
.unwrap_or_default()
.iter()
.filter(|issue| {
issue
.milestones
.as_ref()
.is_some_and(|milestones| milestones.iter().any(|m| m == name))
})
.map(IssueRow::from_issue)
.collect()
}
fn clip(text: &str, width: usize) -> String {
text.chars().take(width).collect()
}
pub fn render(app: &App, f: &mut Frame, area: Rect, title: &str, rows: &[IssueRow]) {
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {title} "))
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
f.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
if rows.is_empty() {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"No issues.",
Style::default().fg(Color::DarkGray),
))),
inner,
);
return;
}
let inner_w = inner.width as usize;
let num_w = 6usize;
let status_w = 12usize.min(inner_w.saturating_sub(num_w + 2));
let title_w = inner_w.saturating_sub(num_w + 1 + status_w);
let header = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let mut lines: Vec<Line> = Vec::with_capacity(rows.len() + 1);
lines.push(Line::from(Span::styled(
format!(
"{:<num_w$} {:<title_w$} {}",
"#",
clip("Title", title_w),
clip("Status", status_w)
),
header,
)));
for row in rows {
let style = if app.is_hovered(&Action::OpenIssue(row.filename.clone())) {
Style::default()
.fg(Color::White)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(Span::styled(
format!(
"{:<num_w$} {:<title_w$} {}",
format!("#{}", row.number),
clip(&row.title, title_w),
clip(&row.status, status_w)
),
style,
)));
}
f.render_widget(Paragraph::new(lines), inner);
for (index, row) in rows.iter().enumerate() {
let line = 1 + index;
if line >= inner.height as usize {
break;
}
let rect = Rect {
x: inner.x,
y: inner.y + line as u16,
width: inner.width,
height: 1,
};
app.record_region(rect, Action::OpenIssue(row.filename.clone()));
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn row(number: u32, title: &str, filename: &str) -> IssueRow {
IssueRow {
number,
title: title.to_string(),
status: "todo".to_string(),
filename: filename.to_string(),
}
}
#[test]
fn clip_counts_characters_not_bytes() {
assert_eq!(clip("hello", 3), "hel");
assert_eq!(clip("hi", 5), "hi");
assert_eq!(clip("Ünïcode", 2), "Ün");
assert_eq!(clip("", 4), "");
}
#[test]
fn a_row_projects_the_issue_number_title_status_and_filename() {
let candidate = row(7, "Fix the thing", "alice/2026-01-01-fix.md");
assert_eq!(candidate.number, 7);
assert_eq!(candidate.title, "Fix the thing");
assert_eq!(candidate.status, "todo");
assert_eq!(candidate.filename, "alice/2026-01-01-fix.md");
}
}