use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
};
use super::{
app::{App, FocusPane, Modal},
model::{ViewId, finding_cards},
theme::Theme,
};
pub fn draw(frame: &mut Frame<'_>, app: &App) {
let area = frame.area();
frame.render_widget(Block::default().style(Theme::root()), area);
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(5),
Constraint::Length(1),
])
.split(area);
draw_title(frame, root[0], app);
draw_body(frame, root[1], app);
draw_status(frame, root[2], app);
if let Some(modal) = &app.modal {
draw_modal(frame, area, modal);
}
}
fn draw_title(frame: &mut Frame<'_>, area: Rect, app: &App) {
let title = Line::from(vec![
Span::styled(" truth-mirror ", Theme::title()),
Span::styled("control panel", Theme::body()),
Span::raw(" "),
Span::styled(format!("v{}", app.version), Theme::dim()),
Span::raw(" "),
Span::styled(
if app.drain_running {
"● draining"
} else if app.snapshot.dashboard.watcher_alive {
"● watcher"
} else {
"○ watcher down"
},
if app.drain_running || app.snapshot.dashboard.watcher_alive {
Theme::ok()
} else {
Theme::warn()
},
),
]);
frame.render_widget(Paragraph::new(title), area);
}
fn draw_body(frame: &mut Frame<'_>, area: Rect, app: &App) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(18), Constraint::Min(30)])
.split(area);
draw_nav(frame, cols[0], app);
draw_main(frame, cols[1], app);
}
fn draw_nav(frame: &mut Frame<'_>, area: Rect, app: &App) {
let focused = app.focus == FocusPane::Nav;
let items: Vec<ListItem> = ViewId::ALL
.iter()
.map(|view| {
let active = *view == app.view;
let label = format!(" {} {} ", view.short_key(), view.title());
let style = if active {
Theme::selected()
} else if focused {
Theme::nav_active()
} else {
Theme::dim()
};
ListItem::new(Line::from(Span::styled(label, style)))
})
.collect();
let block = Block::default()
.style(Theme::surface())
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Theme::border(focused))
.title(Span::styled(" NAV ", Theme::section()));
frame.render_widget(List::new(items).block(block), area);
}
fn draw_main(frame: &mut Frame<'_>, area: Rect, app: &App) {
match app.view {
ViewId::Dashboard => draw_dashboard(frame, area, app),
ViewId::Queue => draw_queue(frame, area, app),
ViewId::Ledger => {
if app.ledger_detail {
draw_ledger_detail(frame, area, app);
} else {
draw_ledger(frame, area, app);
}
}
ViewId::Config => draw_config(frame, area, app),
ViewId::Debt => draw_debt(frame, area, app),
}
}
fn draw_dashboard(frame: &mut Frame<'_>, area: Rect, app: &App) {
let focused = app.focus == FocusPane::Main;
let block = panel(" DASHBOARD ".to_owned(), focused);
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines = Vec::new();
for (label, value) in &app.snapshot.dashboard.cards {
lines.push(Line::from(vec![
Span::styled(format!(" {label:<12}"), Theme::section()),
Span::styled(value.clone(), Theme::body()),
]));
lines.push(Line::from(""));
}
if !app.snapshot.warnings.is_empty() {
lines.push(Line::from(Span::styled(
format!(" warnings: {}", app.snapshot.warnings.join("; ")),
Theme::warn(),
)));
}
frame.render_widget(Paragraph::new(lines).scroll((app.scroll, 0)), inner);
}
fn draw_queue(frame: &mut Frame<'_>, area: Rect, app: &App) {
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area);
let rows: Vec<ListItem> = if app.snapshot.queue.is_empty() {
vec![ListItem::new(Span::styled(" (queue empty)", Theme::dim()))]
} else {
app.snapshot
.queue
.iter()
.enumerate()
.map(|(i, row)| {
let selected = i == app.queue_sel && app.focus == FocusPane::Main;
let line = format!(
" {} {:8} {:4} pet:{:8} {}",
if selected { "▸" } else { " " },
row.short_sha,
row.age,
row.petition_for,
truncate(&row.subject, 42)
);
ListItem::new(Span::styled(
line,
if selected {
Theme::selected()
} else {
Theme::body()
},
))
})
.collect()
};
let block = panel(
format!(" QUEUE · {} pending ", app.snapshot.queue.len()),
app.focus == FocusPane::Main,
);
frame.render_widget(List::new(rows).block(block), split[0]);
let log_lines: Vec<Line> = if app.drain_log.is_empty() {
vec![Line::from(Span::styled(
" drain log · press d to run watch --once",
Theme::dim(),
))]
} else {
let height = split[1].height.saturating_sub(2) as usize;
let start = app.scroll as usize;
app.drain_log
.iter()
.skip(start)
.take(height)
.map(|line| Line::from(Span::raw(format!(" {line}"))))
.collect()
};
let log_block = panel(" DRAIN LOG ".to_owned(), app.focus == FocusPane::Log);
frame.render_widget(Paragraph::new(log_lines).block(log_block), split[1]);
}
fn draw_ledger(frame: &mut Frame<'_>, area: Rect, app: &App) {
let rows: Vec<ListItem> = if app.snapshot.ledger.is_empty() {
vec![ListItem::new(Span::styled(
" (ledger empty)",
Theme::dim(),
))]
} else {
app.snapshot
.ledger
.iter()
.enumerate()
.map(|(i, row)| {
let selected = i == app.ledger_sel && app.focus == FocusPane::Main;
ListItem::new(Line::from(vec![
Span::raw(if selected { " ▸ " } else { " " }),
Span::styled(
format!("{:8} ", row.short_sha),
if selected {
Theme::selected()
} else {
Theme::body()
},
),
Span::styled(
format!("{:12} ", row.status),
Theme::status_chip(&row.status),
),
Span::styled(format!("{:4} ", row.age), Theme::dim()),
Span::raw(truncate(&row.summary, 48)),
]))
})
.collect()
};
let block = panel(
format!(
" LEDGER · {} · open {} · needs-human {} ",
app.snapshot.ledger.len(),
app.snapshot.stats.unresolved,
app.snapshot.stats.needs_human
),
app.focus == FocusPane::Main,
);
frame.render_widget(List::new(rows).block(block), area);
}
fn draw_ledger_detail(frame: &mut Frame<'_>, area: Rect, app: &App) {
let Some(row) = app.snapshot.ledger.get(app.ledger_sel) else {
return;
};
let entry = app
.snapshot
.ledger_entries
.iter()
.find(|e| e.commit_sha == row.sha);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![
Span::styled("SHA ", Theme::section()),
Span::raw(row.sha.clone()),
]));
lines.push(Line::from(vec![
Span::styled("STATUS ", Theme::section()),
Span::styled(row.status.clone(), Theme::status_chip(&row.status)),
Span::raw(" · "),
Span::styled(row.age.clone(), Theme::dim()),
]));
if let Some(entry) = entry {
lines.push(Line::from(vec![
Span::styled("REVIEWER ", Theme::section()),
Span::raw(format!(
"{}/{}",
entry.reviewer.harness, entry.reviewer.model
)),
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("CLAIM", Theme::section())));
for line in entry.claim.lines() {
lines.push(Line::from(format!(" {line}")));
}
if !entry.summary.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("SUMMARY", Theme::section())));
for line in entry.summary.lines() {
lines.push(Line::from(format!(" {line}")));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("FINDINGS", Theme::section())));
let cards = finding_cards(entry);
if cards.is_empty() {
lines.push(Line::from(Span::styled(" (none)", Theme::dim())));
} else {
for (i, card) in cards.iter().enumerate() {
let selected = i == app.finding_sel;
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(if selected { " ▸" } else { " " }),
Span::styled(
format!(" [{}] ", card.severity.to_uppercase()),
Theme::severity(&card.severity),
),
Span::styled(card.location.clone(), Style::default().fg(Theme::ACCENT)),
Span::raw(" "),
Span::styled(card.title.clone(), Theme::section()),
]));
for wrapped in wrap_text(&card.body, area.width.saturating_sub(6) as usize) {
lines.push(Line::from(format!(" {wrapped}")));
}
if !card.recommendation.is_empty() {
lines.push(Line::from(Span::styled(
format!(" → {}", card.recommendation),
Theme::dim(),
)));
}
}
}
if row.is_actionable_reject {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" [w]aive [p]etition Esc back",
Theme::dim(),
)));
}
}
let block = panel(format!(" LEDGER DETAIL · {} ", row.short_sha), true);
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_config(frame: &mut Frame<'_>, area: Rect, app: &App) {
let rows: Vec<ListItem> = app
.snapshot
.config_rows
.iter()
.enumerate()
.map(|(i, row)| {
let selected = i == app.config_sel && app.focus == FocusPane::Main;
let value_style = if row.is_default {
Theme::dim()
} else if selected {
Theme::selected()
} else {
Theme::body()
};
let mark = if selected { "▸" } else { " " };
let default_tag = if row.is_default { " (default)" } else { "" };
ListItem::new(Line::from(vec![
Span::raw(format!(" {mark} ")),
Span::styled(
format!("{:<28}", row.path),
if selected {
Theme::selected()
} else {
Style::default().fg(Theme::ACCENT)
},
),
Span::styled(
format!("{}{default_tag}", truncate(&row.value, 40)),
value_style,
),
]))
})
.collect();
let block = panel(
format!(" CONFIG · {} ", app.config_path.display()),
app.focus == FocusPane::Main,
);
frame.render_widget(List::new(rows).block(block), area);
}
fn draw_debt(frame: &mut Frame<'_>, area: Rect, app: &App) {
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
let classes: Vec<ListItem> = if app.snapshot.debt.is_empty() {
vec![ListItem::new(Span::styled(
" No FLAG-class debt",
Theme::dim(),
))]
} else {
app.snapshot
.debt
.iter()
.enumerate()
.map(|(i, group)| {
let selected = i == app.debt_sel;
ListItem::new(Span::styled(
format!(
" {} {} ({})",
if selected { "▸" } else { " " },
truncate(&group.class, 28),
group.entries.len()
),
if selected {
Theme::selected()
} else {
Theme::body()
},
))
})
.collect()
};
frame.render_widget(
List::new(classes).block(panel(
" DEBT CLASSES ".to_owned(),
app.focus == FocusPane::Main,
)),
split[0],
);
let mut detail = Vec::new();
if let Some(group) = app.snapshot.debt.get(app.debt_sel) {
if let Some(entry) = group.entries.get(app.debt_entry_sel) {
detail.push(Line::from(vec![
Span::styled(entry.short_sha.clone(), Theme::title()),
Span::raw(format!(
" · {} ago · {}/{}",
entry.age,
app.debt_entry_sel + 1,
group.entries.len()
)),
]));
detail.push(Line::from(truncate(&entry.summary, 80)));
detail.push(Line::from(""));
for card in &entry.findings {
detail.push(Line::from(vec![
Span::styled(
format!("[{}] ", card.severity.to_uppercase()),
Theme::severity(&card.severity),
),
Span::raw(format!("{} {}", card.location, card.title)),
]));
for w in wrap_text(&card.body, split[1].width.saturating_sub(4) as usize) {
detail.push(Line::from(format!(" {w}")));
}
detail.push(Line::from(""));
}
}
} else {
detail.push(Line::from(Span::styled("Select a class", Theme::dim())));
}
frame.render_widget(
Paragraph::new(detail).block(panel(" FINDINGS · h/l entry ".to_owned(), false)),
split[1],
);
}
fn draw_status(frame: &mut Frame<'_>, area: Rect, app: &App) {
let hints = match app.view {
ViewId::Dashboard => "1-5 views r refresh ? help q quit",
ViewId::Queue => "j/k move d drain x remove tab log r refresh q quit",
ViewId::Ledger if app.ledger_detail => "j/k finding w waive p petition Esc back",
ViewId::Ledger => "j/k move Enter detail w waive p petition q quit",
ViewId::Config => "j/k move Enter/e edit (diff → y save) q quit",
ViewId::Debt => "j/k class h/l entry q quit",
};
let line = Line::from(vec![
Span::styled(format!(" {} ", app.status), Theme::body()),
Span::styled("│ ", Theme::dim()),
Span::styled(hints, Theme::dim()),
]);
frame.render_widget(Paragraph::new(line).style(Theme::surface()), area);
}
fn draw_modal(frame: &mut Frame<'_>, area: Rect, modal: &Modal) {
let popup = centered(area, 72, 72);
frame.render_widget(Clear, popup);
match modal {
Modal::Help => {
let block = panel(" HELP ".to_owned(), true);
frame.render_widget(
Paragraph::new(help_text())
.block(block)
.wrap(Wrap { trim: false }),
popup,
);
}
Modal::Confirm { title, body, .. } => {
let block = Block::default()
.style(Theme::surface())
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Theme::warn())
.title(Span::styled(format!(" {title} "), Theme::section()));
let content = format!("{body}\n\n [y] confirm [n]/Esc cancel");
frame.render_widget(Paragraph::new(content).block(block), popup);
}
Modal::TextPrompt {
title,
hint,
buffer,
..
} => {
let block = panel(format!(" {title} "), true);
let content = format!("> {buffer}▌\n\n {hint}");
frame.render_widget(Paragraph::new(content).block(block), popup);
}
Modal::Multiline {
title,
hint,
buffer,
..
} => {
let block = panel(format!(" {title} "), true);
let content = format!("{buffer}\n\n──\n{hint}");
frame.render_widget(
Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false }),
popup,
);
}
Modal::DiffPreview { title, diff, .. } => {
let block = panel(format!(" {title} · y/s write · n cancel "), true);
let lines: Vec<Line> = diff
.lines()
.map(|line| {
let style = if line.starts_with('+') {
Theme::ok()
} else if line.starts_with('-') {
Theme::danger()
} else {
Theme::dim()
};
Line::from(Span::styled(line.to_owned(), style))
})
.collect();
frame.render_widget(Paragraph::new(lines).block(block), popup);
}
}
}
fn panel(title: String, focused: bool) -> Block<'static> {
Block::default()
.style(Theme::surface())
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Theme::border(focused))
.title(Span::styled(title, Theme::section()))
}
fn help_text() -> String {
[
"truth-mirror control panel",
"",
"Global",
" 1-5 switch views",
" Tab/S-Tab cycle panes",
" j/k ↑↓ move selection",
" r refresh now",
" ? this help",
" q quit (confirm)",
"",
"Queue",
" d drain once (watch --once)",
" x remove selected item (confirm)",
"",
"Ledger",
" Enter detail / findings cards",
" w waive (multiline reason, TTY-only)",
" p petition with fix SHA",
"",
"Config",
" Enter/e edit value → diff preview → y save",
" dim values built-in defaults (unset on disk)",
"",
"Debt",
" j/k finding class",
" h/l entry within class",
]
.join("\n")
}
fn truncate(s: &str, max: usize) -> String {
let mut out: String = s.chars().take(max).collect();
if s.chars().count() > max {
out.push('…');
}
out
}
fn wrap_text(s: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![s.to_owned()];
}
let mut lines = Vec::new();
for paragraph in s.split('\n') {
if paragraph.is_empty() {
lines.push(String::new());
continue;
}
let mut current = String::new();
for word in paragraph.split_whitespace() {
if current.is_empty() {
current = word.to_owned();
} else if current.len() + 1 + word.len() <= width {
current.push(' ');
current.push_str(word);
} else {
lines.push(current);
current = word.to_owned();
}
}
if !current.is_empty() {
lines.push(current);
}
}
lines
}
fn centered(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::app::{ConfirmAction, MultiKind, TextKind};
use ratatui::{Terminal, backend::TestBackend, style::Color};
use tempfile::tempdir;
fn assert_explicit_theme(app: &App, label: &str) {
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|frame| draw(frame, app)).expect("render TUI");
let buffer = terminal.backend().buffer();
assert!(
buffer.content().iter().all(|cell| cell.bg != Color::Reset),
"{label}: root render must not leave terminal-default backgrounds"
);
assert!(
buffer.content().iter().all(|cell| cell.fg != Color::Reset),
"{label}: root render must not leave terminal-default foregrounds"
);
assert_eq!(buffer[(119, 0)].bg, Theme::BG, "{label}: root background");
assert_eq!(buffer[(119, 0)].fg, Theme::TEXT, "{label}: root foreground");
assert_eq!(
buffer[(0, 1)].bg,
Theme::SURFACE,
"{label}: navigation surface"
);
assert_eq!(
buffer[(20, 2)].bg,
Theme::SURFACE,
"{label}: content surface"
);
}
#[test]
fn draw_fills_terminal_with_explicit_theme_colors() {
let state_dir = tempdir().expect("state directory");
let config_dir = tempdir().expect("config directory");
let config_path = config_dir.path().join("config.toml");
let mut app = App::new(
state_dir.path().to_owned(),
config_path,
"test".to_owned(),
false,
)
.expect("app");
for view in ViewId::ALL {
app.view = view;
app.modal = None;
assert_explicit_theme(&app, view.title());
}
for (label, modal) in [
("help", Modal::Help),
(
"confirm",
Modal::Confirm {
title: "Confirm".to_owned(),
body: "Proceed?".to_owned(),
action: ConfirmAction::Quit,
},
),
(
"text prompt",
Modal::TextPrompt {
title: "Prompt".to_owned(),
hint: "Enter a value".to_owned(),
buffer: "value".to_owned(),
kind: TextKind::ConfigEdit {
path: "config.key".to_owned(),
},
},
),
(
"multiline",
Modal::Multiline {
title: "Waive".to_owned(),
hint: "Reason".to_owned(),
buffer: "because".to_owned(),
kind: MultiKind::Waiver {
sha: "abc123".to_owned(),
},
},
),
(
"diff preview",
Modal::DiffPreview {
title: "Diff".to_owned(),
diff: "+new\n-old".to_owned(),
path: "config.toml".to_owned(),
value: "new".to_owned(),
},
),
] {
app.modal = Some(modal);
assert_explicit_theme(&app, label);
}
}
}