1use ratatui::Frame;
17use ratatui::layout::Rect;
18use ratatui::style::{Color, Modifier, Style};
19use ratatui::text::{Line, Span};
20use ratatui::widgets::{Block, Borders, Paragraph};
21
22use crate::app::{Action, App};
23use crate::model::Issue;
24use crate::storage::Store;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct IssueRow {
30 pub number: u32,
31 pub title: String,
32 pub status: String,
33 pub filename: String,
36}
37
38impl IssueRow {
39 pub fn from_issue(issue: &Issue) -> Self {
41 IssueRow {
42 number: issue.number,
43 title: issue.title.clone(),
44 status: issue.status.display().to_string(),
45 filename: issue.filename.clone(),
46 }
47 }
48}
49
50pub fn cycle_rows(store: &Store, name: &str) -> Vec<IssueRow> {
52 store
53 .list()
54 .unwrap_or_default()
55 .iter()
56 .filter(|issue| {
57 issue
58 .cycles
59 .as_ref()
60 .is_some_and(|cycles| cycles.iter().any(|c| c == name))
61 })
62 .map(IssueRow::from_issue)
63 .collect()
64}
65
66pub fn milestone_rows(store: &Store, name: &str) -> Vec<IssueRow> {
68 store
69 .list()
70 .unwrap_or_default()
71 .iter()
72 .filter(|issue| {
73 issue
74 .milestones
75 .as_ref()
76 .is_some_and(|milestones| milestones.iter().any(|m| m == name))
77 })
78 .map(IssueRow::from_issue)
79 .collect()
80}
81
82fn clip(text: &str, width: usize) -> String {
85 text.chars().take(width).collect()
86}
87
88pub fn render(app: &App, f: &mut Frame, area: Rect, title: &str, rows: &[IssueRow]) {
94 let block = Block::default()
95 .borders(Borders::ALL)
96 .title(format!(" {title} "))
97 .border_style(Style::default().fg(Color::Cyan));
98 let inner = block.inner(area);
99 f.render_widget(block, area);
100 if inner.width == 0 || inner.height == 0 {
101 return;
102 }
103 if rows.is_empty() {
104 f.render_widget(
105 Paragraph::new(Line::from(Span::styled(
106 "No issues.",
107 Style::default().fg(Color::DarkGray),
108 ))),
109 inner,
110 );
111 return;
112 }
113
114 let inner_w = inner.width as usize;
117 let num_w = 6usize;
118 let status_w = 12usize.min(inner_w.saturating_sub(num_w + 2));
119 let title_w = inner_w.saturating_sub(num_w + 1 + status_w);
120
121 let header = Style::default()
122 .fg(Color::Cyan)
123 .add_modifier(Modifier::BOLD);
124 let mut lines: Vec<Line> = Vec::with_capacity(rows.len() + 1);
125 lines.push(Line::from(Span::styled(
126 format!(
127 "{:<num_w$} {:<title_w$} {}",
128 "#",
129 clip("Title", title_w),
130 clip("Status", status_w)
131 ),
132 header,
133 )));
134 for row in rows {
135 let style = if app.is_hovered(&Action::OpenIssue(row.filename.clone())) {
136 Style::default()
137 .fg(Color::White)
138 .bg(Color::DarkGray)
139 .add_modifier(Modifier::BOLD)
140 } else {
141 Style::default().fg(Color::White)
142 };
143 lines.push(Line::from(Span::styled(
144 format!(
145 "{:<num_w$} {:<title_w$} {}",
146 format!("#{}", row.number),
147 clip(&row.title, title_w),
148 clip(&row.status, status_w)
149 ),
150 style,
151 )));
152 }
153 f.render_widget(Paragraph::new(lines), inner);
154
155 for (index, row) in rows.iter().enumerate() {
158 let line = 1 + index;
159 if line >= inner.height as usize {
160 break;
161 }
162 let rect = Rect {
163 x: inner.x,
164 y: inner.y + line as u16,
165 width: inner.width,
166 height: 1,
167 };
168 app.record_region(rect, Action::OpenIssue(row.filename.clone()));
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 #[allow(clippy::wildcard_imports)]
175 use super::*;
176
177 fn row(number: u32, title: &str, filename: &str) -> IssueRow {
178 IssueRow {
179 number,
180 title: title.to_string(),
181 status: "todo".to_string(),
182 filename: filename.to_string(),
183 }
184 }
185
186 #[test]
187 fn clip_counts_characters_not_bytes() {
188 assert_eq!(clip("hello", 3), "hel");
189 assert_eq!(clip("hi", 5), "hi");
190 assert_eq!(clip("Ünïcode", 2), "Ün");
192 assert_eq!(clip("", 4), "");
193 }
194
195 #[test]
196 fn a_row_projects_the_issue_number_title_status_and_filename() {
197 let candidate = row(7, "Fix the thing", "alice/2026-01-01-fix.md");
198 assert_eq!(candidate.number, 7);
199 assert_eq!(candidate.title, "Fix the thing");
200 assert_eq!(candidate.status, "todo");
201 assert_eq!(candidate.filename, "alice/2026-01-01-fix.md");
202 }
203}