use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::model::Issue;
pub struct DetailState {
pub issue: Issue,
pub scroll_offset: u16,
}
pub fn render(detail: &DetailState, f: &mut Frame, area: Rect) {
let mut lines: Vec<Line<'static>> = Vec::new();
let fields: Vec<(&str, String)> = vec![
("number", detail.issue.number.to_string()),
("title", detail.issue.title.clone()),
("status", detail.issue.status.to_string()),
("priority", detail.issue.priority.to_string()),
("created", detail.issue.created.clone()),
("updated", detail.issue.updated.clone()),
];
for (key, value) in &fields {
lines.push(Line::from(vec![
Span::styled(format!("{key}: "), Style::default().fg(Color::DarkGray)),
Span::raw(value.clone()),
]));
}
if let Some(rendered) = render_assignee(detail.issue.assignee.as_deref()) {
lines.push(Line::from(vec![
Span::styled("assignee: ", Style::default().fg(Color::DarkGray)),
Span::raw(rendered),
]));
}
if let Some(ref estimate) = detail.issue.estimate {
lines.push(Line::from(vec![
Span::styled("estimate: ", Style::default().fg(Color::DarkGray)),
Span::raw(estimate.clone()),
]));
}
if let Some(ref commit) = detail.issue.commit {
lines.push(Line::from(vec![
Span::styled("commit: ", Style::default().fg(Color::DarkGray)),
Span::raw(commit.clone()),
]));
}
if !detail.issue.labels.is_empty() {
lines.push(Line::from(vec![
Span::styled("labels: ", Style::default().fg(Color::DarkGray)),
Span::raw(detail.issue.labels.join(", ")),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"---",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(""));
for line_text in detail.issue.body.lines() {
lines.push(Line::from(Span::raw(line_text.to_string())));
}
let para = Paragraph::new(lines)
.scroll((detail.scroll_offset, 0))
.block(
Block::default()
.borders(Borders::ALL)
.title("Detail [Esc=back e=edit]"),
);
f.render_widget(para, area);
}
pub fn render_assignee(assignee: Option<&str>) -> Option<String> {
let raw = assignee?;
if raw.contains('<') && raw.contains('>') {
return Some(raw.to_string());
}
if raw.starts_with('@') {
return Some(raw.to_string());
}
Some(format!("@{raw}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::*;
fn sample_issue() -> Issue {
Issue {
number: 1,
title: "Test Issue".into(),
status: Status::new("todo"),
labels: vec!["bug".into()],
priority: Priority::High,
assignee: Some("alice".into()),
estimate: Some("2".into()),
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: "test.md".into(),
body: "Line one\n\nLine two".into(),
}
}
#[test]
fn render_smoke_small_frame() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(10, 5);
let mut terminal = Terminal::new(backend).unwrap();
let detail = DetailState {
issue: sample_issue(),
scroll_offset: 0,
};
terminal.draw(|f| render(&detail, f, f.area())).unwrap();
}
#[test]
fn render_smoke_large_frame() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(80, 40);
let mut terminal = Terminal::new(backend).unwrap();
let detail = DetailState {
issue: sample_issue(),
scroll_offset: 0,
};
terminal.draw(|f| render(&detail, f, f.area())).unwrap();
}
#[test]
fn render_scroll_offset() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(80, 10);
let mut terminal = Terminal::new(backend).unwrap();
let detail = DetailState {
issue: sample_issue(),
scroll_offset: 5,
};
terminal.draw(|f| render(&detail, f, f.area())).unwrap();
}
#[test]
fn assignee_render_identity_verbatim_bare_gets_at() {
assert_eq!(render_assignee(None), None);
assert_eq!(
render_assignee(Some("Herbert Maschke <thyseus@pm.me>")),
Some("Herbert Maschke <thyseus@pm.me>".to_string())
);
assert_eq!(render_assignee(Some("alice")), Some("@alice".to_string()));
assert_eq!(render_assignee(Some("@alice")), Some("@alice".to_string()));
}
}