use super::{
AssigneeBadge, FormField, IssueFormState, IssueRow, IssuesPanelMode, IssuesPanelState,
};
use oxicode_textarea::TextAreaState;
use ratatui::Frame;
use ratatui::layout::{Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, FrameExt, List, ListItem, Paragraph};
pub(crate) fn render_issues_panel(frame: &mut Frame<'_>, area: Rect, panel: &IssuesPanelState) {
match &panel.mode {
IssuesPanelMode::List => render_list(frame, area, panel),
IssuesPanelMode::FilterInput(buf) => {
render_list(frame, area, panel);
render_filter_hint(frame, area, buf);
}
IssuesPanelMode::Detail { id, scroll } => render_detail(frame, area, panel, *id, *scroll),
IssuesPanelMode::Form(form) => render_form(frame, area, form, panel),
}
}
fn render_list(frame: &mut Frame<'_>, area: Rect, panel: &IssuesPanelState) {
let busy_suffix = if panel.pending { " (busy\u{2026})" } else { "" };
let title = format!(
"Issues \u{2014} {} ({}){}",
match panel.status_filter {
Some(oxicode_sdk::Status::Open) => "open",
Some(oxicode_sdk::Status::Closed) => "closed",
None => "all",
},
panel.rows.len(),
busy_suffix,
);
let items: Vec<ListItem> = panel
.rows
.iter()
.map(|row| ListItem::new(Line::from(row_spans(row))))
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut list_state = ratatui::widgets::ListState::default();
list_state.select(Some(panel.selected.min(panel.rows.len().saturating_sub(1))));
frame.render_stateful_widget(list, area, &mut list_state);
render_error_footer(frame, area, panel.error.as_deref());
}
fn render_error_footer(frame: &mut Frame<'_>, area: Rect, err: Option<&str>) {
let Some(err) = err else { return };
let footer = Rect {
y: area.y + area.height.saturating_sub(1),
height: 1,
..area
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
err.to_string(),
Style::default().fg(ratatui::style::Color::Red),
))),
footer,
);
}
fn row_spans(row: &IssueRow) -> Vec<Span<'static>> {
let badge = match &row.assignee_badge {
Some(AssigneeBadge::Live(s)) => format!(" [working: {s}]"),
Some(AssigneeBadge::Stale(s)) => format!(" [stale claim: {s}]"),
None => String::new(),
};
vec![Span::raw(format!(
"#{} [{}] {} {} {}{}",
row.id,
row.priority,
row.title,
row.status,
row.labels.join(","),
badge
))]
}
fn render_filter_hint(frame: &mut Frame<'_>, area: Rect, buf: &str) {
let hint = Rect {
y: area.y + area.height.saturating_sub(3),
height: 3,
..area
};
let text = format!(
"filter: {buf}\nEnter: apply · Esc: cancel · Ctrl+U: clear · syntax: priority=critical label=auth text"
);
frame.render_widget(
Paragraph::new(text).block(Block::default().borders(Borders::ALL)),
hint,
);
}
fn render_detail(
frame: &mut Frame<'_>,
area: Rect,
panel: &IssuesPanelState,
id: u32,
scroll: usize,
) {
let Some(row) = panel.rows.iter().find(|r| r.id == id) else {
render_list(frame, area, panel);
return;
};
let badge = match &row.assignee_badge {
Some(AssigneeBadge::Live(s)) => format!(" [working: {s}]"),
Some(AssigneeBadge::Stale(s)) => format!(" [stale claim: {s}]"),
None => String::new(),
};
let stamp = |t: chrono::DateTime<chrono::Utc>| t.format("%Y-%m-%d %H:%M").to_string();
let header = format!(
"#{} {} [{}] {} labels: {}",
row.id,
row.status,
row.priority,
row.title,
row.labels.join(",")
);
let meta_header = format!(
"assignee:{} created {} updated {} closed {}",
badge,
stamp(row.created_at),
stamp(row.updated_at),
row.closed_at.map(stamp).unwrap_or_else(|| "—".into()),
);
let busy_suffix = if panel.pending { " (busy\u{2026})" } else { "" };
let block = Block::default().borders(Borders::ALL).title(format!(
"Issue #{id} \u{2014} Esc: back, e: edit, c: close, r: reopen{busy_suffix}"
));
let inner = block.inner(area);
frame.render_widget(block, area);
let header_area = Rect { height: 1, ..inner };
frame.render_widget(Paragraph::new(header), header_area);
let meta_area = Rect {
y: inner.y + 1,
height: 1,
..inner
};
frame.render_widget(
Paragraph::new(meta_header).style(Style::default().add_modifier(Modifier::DIM)),
meta_area,
);
let body_area = Rect {
y: inner.y + 3,
height: inner.height.saturating_sub(3),
..inner
};
let body_text = panel.detail_body_cache.as_deref().unwrap_or("(loading…)");
let lines =
oxicode_vtui::tui::ui::markdown::render_markdown(body_text, body_area.width as usize);
let styles = oxicode_vtui::theme::active_styles();
let ratatui_lines: Vec<Line> = lines
.into_iter()
.skip(scroll)
.map(|segs| {
Line::from(
segs.into_iter()
.map(|seg| {
let style = crate::tui_vt::main_loop::segment_style(
&seg,
Style::default(),
&styles,
);
Span::styled(seg.text, style)
})
.collect::<Vec<_>>(),
)
})
.collect();
frame.render_widget(Paragraph::new(ratatui_lines), body_area);
render_error_footer(frame, area, panel.error.as_deref());
}
fn render_form(frame: &mut Frame<'_>, area: Rect, form: &IssueFormState, panel: &IssuesPanelState) {
let title = if form.editing_id.is_some() {
"Edit issue — Tab: next field, Ctrl+Enter: save, Esc: cancel"
} else {
"New issue — Tab: next field, Ctrl+Enter: create, Esc: cancel"
};
let block = Block::default().borders(Borders::ALL).title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
let focus_marker = |f: FormField| if form.focus == f { "> " } else { " " };
let title_line = format!("{}Title: {}", focus_marker(FormField::Title), form.title);
let priority_line = format!(
"{}Priority (\u{2190}/\u{2192}): {}",
focus_marker(FormField::Priority),
form.priority
);
let labels_line = format!(
"{}Labels (comma-separated): {}",
focus_marker(FormField::Labels),
form.labels_input
);
frame.render_widget(
Paragraph::new(vec![
Line::from(title_line),
Line::from(priority_line),
Line::from(labels_line),
Line::from(format!("{}Body:", focus_marker(FormField::Body))),
]),
Rect { height: 4, ..inner },
);
let body_area = Rect {
y: inner.y + 4,
height: inner.height.saturating_sub(4),
..inner
};
frame.render_widget_ref(&form.body, body_area);
if form.focus == FormField::Body
&& let Some((cx, cy)) = form
.body
.cursor_pos_with_state(body_area, TextAreaState::default())
{
frame.set_cursor_position(Position::new(cx, cy));
}
render_error_footer(frame, area, panel.error.as_deref());
}
#[cfg(test)]
mod render_tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::*;
use oxicode_sdk::{Priority, Status};
fn sample_row() -> IssueRow {
use chrono::TimeZone;
let t = chrono::Utc.with_ymd_and_hms(2026, 8, 27, 12, 0, 0).unwrap();
IssueRow {
id: 1,
title: "sample issue".into(),
status: Status::Open,
priority: Priority::High,
labels: vec!["auth".into()],
assignee_badge: Some(AssigneeBadge::Live("tui".into())),
created_at: t,
updated_at: t,
closed_at: None,
}
}
fn draw(panel: &IssuesPanelState, width: u16, height: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| render_issues_panel(frame, frame.area(), panel))
.unwrap();
terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
}
#[test]
fn list_mode_renders_without_panicking() {
let panel = IssuesPanelState {
rows: vec![sample_row()],
..Default::default()
};
let text = draw(&panel, 80, 24);
assert!(text.contains("sample issue"), "list body missing: {text:?}");
}
#[test]
fn detail_mode_renders_without_panicking() {
let panel = IssuesPanelState {
rows: vec![sample_row()],
mode: IssuesPanelMode::Detail { id: 1, scroll: 0 },
detail_body_cache: Some("# Heading\n\nSome **body** text.".into()),
..Default::default()
};
let text = draw(&panel, 100, 24);
assert!(text.contains("Issue #1"), "detail title missing: {text:?}");
assert!(
text.contains("[working: tui]"),
"assignee badge missing from detail meta header: {text:?}"
);
assert!(
text.contains("created 2026-08-27 12:00")
&& text.contains("updated 2026-08-27 12:00")
&& text.contains("closed —"),
"timestamp line missing from detail meta header: {text:?}"
);
}
#[test]
fn form_mode_renders_without_panicking() {
let panel = IssuesPanelState {
mode: IssuesPanelMode::Form(Box::default()),
..Default::default()
};
let text = draw(&panel, 80, 24);
assert!(text.contains("New issue —"), "form title missing: {text:?}");
}
#[test]
fn form_body_focus_and_error_footer_render() {
let form = IssueFormState {
focus: FormField::Body,
..Default::default()
};
let panel = IssuesPanelState {
mode: IssuesPanelMode::Form(Box::new(form)),
error: Some("boom".into()),
..Default::default()
};
let text = draw(&panel, 80, 24);
assert!(text.contains("boom"), "error footer missing: {text:?}");
}
#[test]
fn filter_input_mode_renders_without_panicking() {
let panel = IssuesPanelState {
rows: vec![sample_row()],
mode: IssuesPanelMode::FilterInput("priority=high".into()),
..Default::default()
};
let text = draw(&panel, 80, 24);
assert!(
text.contains("filter: priority=high"),
"filter hint missing: {text:?}"
);
}
#[test]
fn empty_list_tiny_viewport_does_not_panic() {
let panel = IssuesPanelState::default();
draw(&panel, 20, 1);
}
#[test]
fn filter_input_tiny_viewport_does_not_panic() {
let panel = IssuesPanelState {
rows: vec![sample_row()],
mode: IssuesPanelMode::FilterInput("priority=high".into()),
..Default::default()
};
draw(&panel, 20, 1);
}
#[test]
fn detail_tiny_viewport_does_not_panic() {
let panel = IssuesPanelState {
rows: vec![sample_row()],
mode: IssuesPanelMode::Detail { id: 1, scroll: 0 },
detail_body_cache: Some("body".into()),
..Default::default()
};
draw(&panel, 20, 1);
}
#[test]
fn form_tiny_viewport_does_not_panic() {
let panel = IssuesPanelState {
mode: IssuesPanelMode::Form(Box::default()),
..Default::default()
};
draw(&panel, 20, 1);
}
#[test]
fn detail_mode_renders_error_footer() {
let panel = IssuesPanelState {
rows: vec![sample_row()],
mode: IssuesPanelMode::Detail { id: 1, scroll: 0 },
detail_body_cache: Some("body".into()),
error: Some("boom-detail".into()),
..Default::default()
};
let text = draw(&panel, 80, 24);
assert!(
text.contains("boom-detail"),
"detail error footer missing: {text:?}"
);
}
#[test]
fn list_mode_renders_busy_marker_when_pending() {
let panel = IssuesPanelState {
rows: vec![sample_row()],
pending: true,
..Default::default()
};
let text = draw(&panel, 80, 24);
assert!(text.contains("busy"), "list busy marker missing: {text:?}");
}
#[test]
fn detail_mode_renders_busy_marker_when_pending() {
let panel = IssuesPanelState {
rows: vec![sample_row()],
mode: IssuesPanelMode::Detail { id: 1, scroll: 0 },
detail_body_cache: Some("body".into()),
pending: true,
..Default::default()
};
let text = draw(&panel, 80, 24);
assert!(
text.contains("busy"),
"detail busy marker missing: {text:?}"
);
}
}