Skip to main content

tix/ui/
issue_table.rs

1//! The flat issue table shared by the Cycle and Milestones surfaces.
2//!
3//! Both surfaces answer the same question — which issues belong to this cycle /
4//! milestone? — and both must answer it the same way: a header, one row per
5//! issue showing `#number`, title, and status, and a click target per row that
6//! opens the issue's detail. One [`render`] taking the rows and a title is what
7//! keeps the two from drifting, and the two row builders ([`cycle_rows`],
8//! [`milestone_rows`]) are the only places the membership rule lives: exact
9//! string membership in `cycles` / `milestones`, the same rule the planner
10//! counts with.
11//!
12//! Membership reads [`Store::list`], the active set the statistics above the
13//! table are computed from, so a row and a count can never disagree about an
14//! archived issue.
15
16use 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/// One issue as the flat table lists it: what a row shows and what a click on
27/// it opens.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct IssueRow {
30    pub number: u32,
31    pub title: String,
32    pub status: String,
33    /// The issue's store-relative filename, carried by
34    /// [`Action::OpenIssue`].
35    pub filename: String,
36}
37
38impl IssueRow {
39    /// Project a stored issue into a table row.
40    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
50/// Every issue whose `cycles` names `name` exactly, in store order.
51pub 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
66/// Every issue whose `milestones` names `name` exactly, in store order.
67pub 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
82/// `text`, cut to at most `width` characters (not bytes — a title may be
83/// Unicode, and slicing it by bytes would panic).
84fn clip(text: &str, width: usize) -> String {
85    text.chars().take(width).collect()
86}
87
88/// Draw `rows` as a flat, hoverable, clickable table under a bordered `title`.
89///
90/// A hovered row is highlighted and records one [`Action::OpenIssue`] region
91/// per row, so a click opens the issue exactly like a card on the board. An
92/// empty list renders a short "No issues." line rather than an empty box.
93pub 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    // Width-aware columns: the title absorbs whatever the number and status
115    // columns leave, so the status never falls off the right edge.
116    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    // Rows sit below the header line; only the ones the area actually holds
156    // get a click target, so a short pane never records an invisible row.
157    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        // Byte slicing would panic mid-codepoint; `chars` does not.
191        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}