use super::app::{App, View};
use crate::theme::{phase_semantic, status_icon, status_semantic};
use ratatui::{
prelude::*,
symbols::border,
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Row, Table, Wrap},
};
fn status_style(status: &str) -> Style {
Style::default().fg(status_semantic(status).to_ratatui())
}
fn phase_style(phase: &str) -> Style {
Style::default().fg(phase_semantic(phase).to_ratatui())
}
fn breadcrumb(app: &App) -> String {
match app.view {
View::Dashboard => "Dashboard".to_string(),
View::RfcList => "Dashboard > RFCs".to_string(),
View::AdrList => "Dashboard > ADRs".to_string(),
View::WorkList => "Dashboard > Work".to_string(),
View::RfcDetail(idx) => app
.index
.rfcs
.get(idx)
.map(|rfc| format!("Dashboard > RFCs > {}", rfc.rfc.rfc_id))
.unwrap_or_else(|| "Dashboard > RFCs".to_string()),
View::AdrDetail(idx) => app
.index
.adrs
.get(idx)
.map(|adr| format!("Dashboard > ADRs > {}", adr.meta().id))
.unwrap_or_else(|| "Dashboard > ADRs".to_string()),
View::WorkDetail(idx) => app
.index
.work_items
.get(idx)
.map(|item| format!("Dashboard > Work > {}", item.meta().id))
.unwrap_or_else(|| "Dashboard > Work".to_string()),
View::ClauseDetail(rfc_idx, clause_idx) => app
.index
.rfcs
.get(rfc_idx)
.and_then(|rfc| rfc.clauses.get(clause_idx).map(|clause| (rfc, clause)))
.map(|(rfc, clause)| {
format!(
"Dashboard > RFCs > {} > {}",
rfc.rfc.rfc_id, clause.spec.clause_id
)
})
.unwrap_or_else(|| "Dashboard > RFCs".to_string()),
}
}
fn header_status(app: &mut App) -> String {
match app.view {
View::Dashboard => format!(
"RFC {} | ADR {} | Work {}",
app.index.rfcs.len(),
app.index.adrs.len(),
app.index.work_items.len()
),
View::RfcList | View::AdrList | View::WorkList => {
let total = app.list_total_len();
let shown = app.list_len();
let mut parts = vec![format!("Shown {}/{}", shown, total)];
if shown > 0 {
parts.push(format!("Sel {}/{}", app.selected + 1, shown));
}
if app.filter_mode {
parts.push(format!("Filter: /{}_", app.filter_query));
} else if app.filter_active() {
parts.push(format!("Filter: {}", app.filter_query));
}
parts.join(" | ")
}
_ => String::new(),
}
}
fn draw_header(frame: &mut Frame, app: &mut App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(10), Constraint::Length(30)])
.split(inner);
let left = Paragraph::new(Line::from(vec![
Span::styled("govctl", Style::default().fg(Color::Cyan).bold()),
Span::raw(" "),
Span::raw(breadcrumb(app)),
]))
.alignment(Alignment::Left);
let right = Paragraph::new(header_status(app)).alignment(Alignment::Right);
frame.render_widget(left, chunks[0]);
frame.render_widget(right, chunks[1]);
}
fn keybind_line(bindings: &[&str]) -> Line<'static> {
let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
for chunk in bindings.chunks(2) {
if chunk.len() == 2 {
spans.push(Span::styled("[", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
chunk[0].to_string(),
Style::default().fg(Color::Cyan).bold(),
));
spans.push(Span::styled("] ", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
format!("{} ", chunk[1]),
Style::default().fg(Color::DarkGray),
));
}
}
Line::from(spans)
}
fn draw_footer(frame: &mut Frame, area: Rect, bindings: &[&str], status: Option<&str>) {
let block = Block::default()
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(10), Constraint::Length(30)])
.split(inner);
let left = Paragraph::new(keybind_line(bindings)).alignment(Alignment::Center);
let right = Paragraph::new(status.unwrap_or("")).alignment(Alignment::Right);
frame.render_widget(left, chunks[0]);
frame.render_widget(right, chunks[1]);
}
pub fn draw(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), ])
.split(area);
draw_header(frame, app, chunks[0]);
app.content_height = chunks[1].height;
let mut footer_status = None;
let bindings: &[&str] = match app.view {
View::Dashboard => &[
"1/r", "RFCs", "2/a", "ADRs", "3/w", "Work", "?", "Help", "q", "Quit",
],
View::RfcList | View::AdrList | View::WorkList => &[
"j/k", "Navigate", "Enter", "View", "Esc", "Back", "/", "Filter", "g/G", "Jump", "?",
"Help", "q", "Quit",
],
View::RfcDetail(_) => &[
"j/k",
"Navigate",
"Enter",
"View Clause",
"Esc",
"Back",
"?",
"Help",
"q",
"Quit",
],
View::AdrDetail(_) | View::WorkDetail(_) | View::ClauseDetail(_, _) => &[
"j/k", "Scroll", "^d/^u", "Page", "Esc", "Back", "?", "Help", "q", "Quit",
],
};
match app.view {
View::Dashboard => draw_dashboard(frame, app, chunks[1]),
View::RfcList => draw_rfc_list(frame, app, chunks[1]),
View::AdrList => draw_adr_list(frame, app, chunks[1]),
View::WorkList => draw_work_list(frame, app, chunks[1]),
View::RfcDetail(idx) => draw_rfc_detail(frame, app, chunks[1], idx),
View::AdrDetail(idx) => {
let total = draw_adr_detail(frame, app, chunks[1], idx);
let max_scroll = total.saturating_sub(1) as u16;
if app.scroll > max_scroll {
app.scroll = max_scroll;
}
footer_status = Some(format!("Scroll {}/{}", app.scroll + 1, total));
}
View::WorkDetail(idx) => {
let total = draw_work_detail(frame, app, chunks[1], idx);
let max_scroll = total.saturating_sub(1) as u16;
if app.scroll > max_scroll {
app.scroll = max_scroll;
}
footer_status = Some(format!("Scroll {}/{}", app.scroll + 1, total));
}
View::ClauseDetail(rfc_idx, clause_idx) => {
let total = draw_clause_detail(frame, app, chunks[1], rfc_idx, clause_idx);
let max_scroll = total.saturating_sub(1) as u16;
if app.scroll > max_scroll {
app.scroll = max_scroll;
}
footer_status = Some(format!("Scroll {}/{}", app.scroll + 1, total));
}
}
draw_footer(frame, chunks[2], bindings, footer_status.as_deref());
if app.show_help {
draw_help_overlay(frame, app);
}
}
fn wrapped_line_count(lines: &[Line], render_width: u16) -> usize {
if render_width == 0 {
return lines.len();
}
let w = render_width as usize;
lines
.iter()
.map(|line| {
let display_width = line.width();
if display_width == 0 {
1
} else {
display_width.div_ceil(w)
}
})
.sum()
}
fn rounded_block(title: &str) -> Block<'_> {
Block::default()
.title(format!(" {} ", title))
.borders(Borders::ALL)
.border_set(border::ROUNDED)
}
fn draw_dashboard(frame: &mut Frame, app: &mut App, area: Rect) {
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(34),
Constraint::Percentage(33),
])
.split(area);
let rfc_stats = build_rfc_stats(app);
frame.render_widget(rfc_stats, content_chunks[0]);
let adr_stats = build_adr_stats(app);
frame.render_widget(adr_stats, content_chunks[1]);
let work_stats = build_work_stats(app);
frame.render_widget(work_stats, content_chunks[2]);
}
fn build_rfc_stats(app: &App) -> Paragraph<'static> {
let mut lines = vec![Line::from("")];
let mut draft = 0;
let mut normative = 0;
let mut deprecated = 0;
for rfc in &app.index.rfcs {
match rfc.rfc.status.as_ref() {
"draft" => draft += 1,
"normative" => normative += 1,
"deprecated" => deprecated += 1,
_ => {}
}
}
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("○", Style::default().fg(Color::Yellow)),
Span::raw(format!(" Draft: {}", draft)),
]));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("●", Style::default().fg(Color::Green)),
Span::raw(format!(" Normative: {}", normative)),
]));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("✗", Style::default().fg(Color::Red)),
Span::raw(format!(" Deprecated: {}", deprecated)),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("Σ", Style::default().fg(Color::Cyan).bold()),
Span::styled(
format!(" Total: {}", app.index.rfcs.len()),
Style::default().bold(),
),
]));
Paragraph::new(lines).block(
Block::default()
.title(" 📋 RFCs ")
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(Color::Blue)),
)
}
fn build_adr_stats(app: &App) -> Paragraph<'static> {
let mut lines = vec![Line::from("")];
let mut proposed = 0;
let mut accepted = 0;
let mut superseded = 0;
for adr in &app.index.adrs {
match adr.meta().status.as_ref() {
"proposed" => proposed += 1,
"accepted" => accepted += 1,
"superseded" => superseded += 1,
_ => {}
}
}
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("○", Style::default().fg(Color::Yellow)),
Span::raw(format!(" Proposed: {}", proposed)),
]));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("●", Style::default().fg(Color::Green)),
Span::raw(format!(" Accepted: {}", accepted)),
]));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("✗", Style::default().fg(Color::Red)),
Span::raw(format!(" Superseded: {}", superseded)),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("Σ", Style::default().fg(Color::Cyan).bold()),
Span::styled(
format!(" Total: {}", app.index.adrs.len()),
Style::default().bold(),
),
]));
Paragraph::new(lines).block(
Block::default()
.title(" 📝 ADRs ")
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(Color::Green)),
)
}
fn build_work_stats(app: &App) -> Paragraph<'static> {
let mut lines = vec![Line::from("")];
let mut queue = 0;
let mut active = 0;
let mut done = 0;
for item in &app.index.work_items {
match item.meta().status.as_ref() {
"queue" => queue += 1,
"active" => active += 1,
"done" => done += 1,
_ => {}
}
}
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("○", Style::default().fg(Color::Yellow)),
Span::raw(format!(" Queue: {}", queue)),
]));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("◉", Style::default().fg(Color::Green)),
Span::raw(format!(" Active: {}", active)),
]));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("●", Style::default().fg(Color::Green)),
Span::raw(format!(" Done: {}", done)),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("Σ", Style::default().fg(Color::Cyan).bold()),
Span::styled(
format!(" Total: {}", app.index.work_items.len()),
Style::default().bold(),
),
]));
Paragraph::new(lines).block(
Block::default()
.title(" 📌 Work Items ")
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(Color::Yellow)),
)
}
fn draw_rfc_list(frame: &mut Frame, app: &mut App, area: Rect) {
let indices = app.list_indices();
let rows: Vec<Row> = indices
.iter()
.filter_map(|&idx| app.index.rfcs.get(idx))
.map(|rfc| {
let status = rfc.rfc.status.as_ref();
let phase = rfc.rfc.phase.as_ref();
Row::new(vec![
Line::from(rfc.rfc.rfc_id.clone()),
Line::from(rfc.rfc.title.clone()),
Line::from(vec![
Span::styled(format!("{} ", status_icon(status)), status_style(status)),
Span::styled(status.to_string(), status_style(status)),
]),
Line::from(Span::styled(phase.to_string(), phase_style(phase))),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Min(30),
Constraint::Length(14),
Constraint::Length(10),
],
)
.header(
Row::new(vec!["ID", "Title", "Status", "Phase"])
.style(Style::default().bold().fg(Color::Cyan))
.bottom_margin(1),
)
.row_highlight_style(Style::default().bg(Color::DarkGray))
.block(rounded_block("📋 RFCs").border_style(Style::default().fg(Color::Blue)));
frame.render_stateful_widget(table, area, &mut app.table_state);
}
fn draw_adr_list(frame: &mut Frame, app: &mut App, area: Rect) {
let indices = app.list_indices();
let rows: Vec<Row> = indices
.iter()
.filter_map(|&idx| app.index.adrs.get(idx))
.map(|adr| {
let meta = adr.meta();
let status = meta.status.as_ref();
Row::new(vec![
Line::from(meta.id.clone()),
Line::from(meta.title.clone()),
Line::from(vec![
Span::styled(format!("{} ", status_icon(status)), status_style(status)),
Span::styled(status.to_string(), status_style(status)),
]),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(12),
Constraint::Min(40),
Constraint::Length(14),
],
)
.header(
Row::new(vec!["ID", "Title", "Status"])
.style(Style::default().bold().fg(Color::Cyan))
.bottom_margin(1),
)
.row_highlight_style(Style::default().bg(Color::DarkGray))
.block(rounded_block("📝 ADRs").border_style(Style::default().fg(Color::Green)));
frame.render_stateful_widget(table, area, &mut app.table_state);
}
fn draw_work_list(frame: &mut Frame, app: &mut App, area: Rect) {
let indices = app.list_indices();
let rows: Vec<Row> = indices
.iter()
.filter_map(|&idx| app.index.work_items.get(idx))
.map(|item| {
let meta = item.meta();
let status = meta.status.as_ref();
Row::new(vec![
Line::from(meta.id.clone()),
Line::from(meta.title.clone()),
Line::from(vec![
Span::styled(format!("{} ", status_icon(status)), status_style(status)),
Span::styled(status.to_string(), status_style(status)),
]),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(20),
Constraint::Min(40),
Constraint::Length(12),
],
)
.header(
Row::new(vec!["ID", "Title", "Status"])
.style(Style::default().bold().fg(Color::Cyan))
.bottom_margin(1),
)
.row_highlight_style(Style::default().bg(Color::DarkGray))
.block(rounded_block("📌 Work Items").border_style(Style::default().fg(Color::Yellow)));
frame.render_stateful_widget(table, area, &mut app.table_state);
}
fn draw_rfc_detail(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) {
let Some(rfc) = app.index.rfcs.get(idx) else {
return;
};
let status = rfc.rfc.status.as_ref();
let phase = rfc.rfc.phase.as_ref();
let mut header_lines = vec![
Line::from(vec![
Span::styled("ID: ", Style::default().fg(Color::DarkGray)),
Span::styled(rfc.rfc.rfc_id.clone(), Style::default().bold()),
]),
Line::from(vec![
Span::styled("Title: ", Style::default().fg(Color::DarkGray)),
Span::raw(rfc.rfc.title.clone()),
]),
Line::from(vec![
Span::styled("Version: ", Style::default().fg(Color::DarkGray)),
Span::styled(rfc.rfc.version.clone(), Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::styled("Status: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{} ", status_icon(status)), status_style(status)),
Span::styled(status.to_string(), status_style(status)),
]),
Line::from(vec![
Span::styled("Phase: ", Style::default().fg(Color::DarkGray)),
Span::styled(phase.to_string(), phase_style(phase)),
]),
Line::from(vec![
Span::styled("Owners: ", Style::default().fg(Color::DarkGray)),
Span::raw(rfc.rfc.owners.join(", ")),
]),
];
if !rfc.rfc.refs.is_empty() {
header_lines.push(Line::from(vec![
Span::styled("Refs: ", Style::default().fg(Color::DarkGray)),
Span::raw(rfc.rfc.refs.join(", ")),
]));
}
let header_height = (header_lines.len() as u16) + 2;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(header_height), Constraint::Min(5)])
.split(area);
let title = format!("📋 {}", rfc.rfc.rfc_id);
let header = Paragraph::new(header_lines)
.block(rounded_block(&title).border_style(Style::default().fg(Color::Blue)));
frame.render_widget(header, chunks[0]);
let clause_items: Vec<ListItem> = rfc
.clauses
.iter()
.map(|clause| {
let clause_status = clause.spec.status.as_ref();
ListItem::new(Line::from(vec![
Span::styled(
format!("{} ", status_icon(clause_status)),
status_style(clause_status),
),
Span::styled(
clause.spec.clause_id.clone(),
Style::default().fg(Color::Blue).bold(),
),
Span::raw(" — "),
Span::raw(clause.spec.title.clone()),
]))
})
.collect();
let clause_list = List::new(clause_items)
.block(rounded_block("Clauses").border_style(Style::default().fg(Color::Cyan)))
.highlight_style(Style::default().bg(Color::DarkGray))
.highlight_symbol("▶ ");
frame.render_stateful_widget(clause_list, chunks[1], &mut app.clause_list_state);
}
fn draw_adr_detail(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) -> usize {
let Some(adr) = app.index.adrs.get(idx) else {
return 0;
};
let text = crate::render::render_adr(adr)
.map(|md| crate::terminal_md::render_to_tui_text(&md))
.unwrap_or_default();
let title = format!("📝 {}", adr.meta().id);
let block = rounded_block(&title).border_style(Style::default().fg(Color::Green));
let inner_width = block.inner(area).width;
let total_lines = wrapped_line_count(&text.lines, inner_width);
let content = Paragraph::new(text)
.wrap(Wrap { trim: false })
.scroll((app.scroll, 0))
.block(block);
frame.render_widget(content, area);
total_lines
}
fn draw_work_detail(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) -> usize {
let Some(item) = app.index.work_items.get(idx) else {
return 0;
};
let text = crate::render::render_work_item(item)
.map(|md| crate::terminal_md::render_to_tui_text(&md))
.unwrap_or_default();
let title = format!("📌 {}", item.meta().id);
let block = rounded_block(&title).border_style(Style::default().fg(Color::Yellow));
let inner_width = block.inner(area).width;
let total_lines = wrapped_line_count(&text.lines, inner_width);
let content = Paragraph::new(text)
.wrap(Wrap { trim: false })
.scroll((app.scroll, 0))
.block(block);
frame.render_widget(content, area);
total_lines
}
fn draw_clause_detail(
frame: &mut Frame,
app: &mut App,
area: Rect,
rfc_idx: usize,
clause_idx: usize,
) -> usize {
let Some(rfc) = app.index.rfcs.get(rfc_idx) else {
return 0;
};
let Some(clause) = rfc.clauses.get(clause_idx) else {
return 0;
};
let mut raw = String::new();
crate::render::render_clause(&mut raw, &rfc.rfc.rfc_id, clause);
let text = crate::terminal_md::render_to_tui_text(&raw);
let title = format!("📜 {}", clause.spec.clause_id);
let block = rounded_block(&title).border_style(Style::default().fg(Color::Magenta));
let inner_width = block.inner(area).width;
let total_lines = wrapped_line_count(&text.lines, inner_width);
let content = Paragraph::new(text)
.wrap(Wrap { trim: false })
.scroll((app.scroll, 0))
.block(block);
frame.render_widget(content, area);
total_lines
}
fn draw_help_overlay(frame: &mut Frame, app: &App) {
let area = frame.area();
let popup = centered_rect(70, 70, area);
frame.render_widget(Clear, popup);
let title = "Help";
let block = rounded_block(title).border_style(Style::default().fg(Color::Cyan));
let mut lines = vec![
Line::from("Global"),
Line::from(" ? Toggle help"),
Line::from(" q Quit"),
Line::from(""),
];
match app.view {
View::Dashboard => {
lines.push(Line::from("Dashboard"));
lines.push(Line::from(" 1/r RFC list"));
lines.push(Line::from(" 2/a ADR list"));
lines.push(Line::from(" 3/w Work list"));
}
View::RfcList | View::AdrList | View::WorkList => {
lines.push(Line::from("List"));
lines.push(Line::from(" j/k Move selection"));
lines.push(Line::from(" Enter View detail"));
lines.push(Line::from(" g/G Top/Bottom"));
lines.push(Line::from(" / Filter"));
lines.push(Line::from(" n/p Next/Prev match (when filtered)"));
lines.push(Line::from(" Esc Back (or clear filter in filter mode)"));
}
View::RfcDetail(_) => {
lines.push(Line::from("RFC Detail"));
lines.push(Line::from(" j/k Move clause selection"));
lines.push(Line::from(" Enter View clause"));
lines.push(Line::from(" Esc Back"));
}
View::AdrDetail(_) | View::WorkDetail(_) | View::ClauseDetail(_, _) => {
lines.push(Line::from("Detail"));
lines.push(Line::from(" j/k Scroll line"));
lines.push(Line::from(" Ctrl+d/u Half-page"));
lines.push(Line::from(" PgDn/Up Full page"));
lines.push(Line::from(" Esc Back"));
}
}
let content = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
frame.render_widget(content, popup);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1]);
horizontal[1]
}