use super::app::ResultsApp;
use super::grouping;
use crate::priority::classification::Severity;
use crate::tui::theme::Theme;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
const HORIZONTAL_MARGIN: u16 = 1;
fn apply_horizontal_margin(area: Rect) -> Rect {
Rect {
x: area.x.saturating_add(HORIZONTAL_MARGIN),
y: area.y,
width: area.width.saturating_sub(HORIZONTAL_MARGIN * 2),
height: area.height,
}
}
pub fn render(frame: &mut Frame, app: &ResultsApp) {
let theme = Theme::default();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), Constraint::Length(2), ])
.split(frame.area());
render_header(frame, app, chunks[0], &theme);
render_list(frame, app, chunks[1], &theme);
render_footer(frame, app, chunks[2], &theme);
}
pub fn render_with_search(frame: &mut Frame, app: &ResultsApp) {
render(frame, app);
let theme = Theme::default();
let area = frame.area();
let search_area = Rect {
x: area.width / 4,
y: 2,
width: area.width / 2,
height: 3,
};
frame.render_widget(Clear, search_area);
let search_text = format!("Search: {}", app.query().search().query());
let search_widget = Paragraph::new(search_text)
.block(
Block::default()
.borders(Borders::ALL)
.title("Search (Esc to cancel, Enter to apply)")
.border_style(Style::default().fg(theme.accent())),
)
.style(Style::default().fg(theme.primary));
frame.render_widget(search_widget, search_area);
}
pub fn render_with_sort_menu(frame: &mut Frame, app: &ResultsApp) {
render(frame, app);
let theme = Theme::default();
let area = frame.area();
let menu_area = Rect {
x: area.width / 3,
y: area.height / 4,
width: area.width / 3,
height: 11,
};
frame.render_widget(Clear, menu_area);
let sort_options = super::sort::SortCriteria::all();
let current_sort = app.query().sort_by();
let items: Vec<ListItem> = sort_options
.iter()
.enumerate()
.map(|(i, criteria)| {
let prefix = if *criteria == current_sort {
"▸ "
} else {
" "
};
let text = format!("{}. {}{}", i + 1, prefix, criteria.display_name());
ListItem::new(text).style(if *criteria == current_sort {
Style::default()
.fg(theme.accent())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.primary)
})
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title("Sort By (press number, Esc to cancel)")
.border_style(Style::default().fg(theme.accent())),
);
frame.render_widget(list, menu_area);
}
pub fn render_with_filter_menu(frame: &mut Frame, app: &ResultsApp) {
render(frame, app);
let theme = Theme::default();
let area = frame.area();
let menu_area = Rect {
x: area.width / 4,
y: area.height / 6,
width: area.width / 2,
height: 16,
};
frame.render_widget(Clear, menu_area);
let mut lines = vec![
Line::from("Severity Filters:"),
Line::from(" 1. Critical"),
Line::from(" 2. High"),
Line::from(" 3. Medium"),
Line::from(" 4. Low"),
Line::from(""),
Line::from("Coverage Filters:"),
Line::from(" n. No Coverage"),
Line::from(" l. Low (0-30%)"),
Line::from(" m. Medium (30-70%)"),
Line::from(" h. High (70-100%)"),
Line::from(""),
Line::from(" c. Clear all filters"),
];
if !app.query().filters().is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Active filters:",
Style::default().fg(theme.accent()),
)));
for filter in app.query().filters() {
lines.push(Line::from(format!(" • {}", filter.display_name())));
}
}
let paragraph = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title("Filters (press key, Esc to cancel)")
.border_style(Style::default().fg(theme.accent())),
);
frame.render_widget(paragraph, menu_area);
}
fn render_header(frame: &mut Frame, app: &ResultsApp, area: Rect, theme: &Theme) {
let analysis = app.analysis();
let count_display = app.count_display();
let header_text = vec![
Line::from(vec![
Span::raw("debtmap results"),
Span::raw(" "),
Span::styled(
count_display.to_string(),
Style::default().fg(theme.primary),
),
Span::raw(" │ "),
Span::raw("score "),
Span::styled(
format!("{:.0}", analysis.total_debt_score),
Style::default().fg(theme.secondary()),
),
Span::raw(" │ "),
Span::styled(
format!("{:.2}/1K loc", analysis.debt_density),
Style::default().fg(theme.muted),
),
]),
Line::from(vec![
Span::styled(
format!("sort {}", app.query().sort_by().display_name()),
Style::default().fg(theme.muted),
),
Span::raw(" │ "),
Span::styled(
format!("filters {}", app.query().filters().len()),
Style::default().fg(theme.muted),
),
]),
];
let header_area = apply_horizontal_margin(area);
let header = Paragraph::new(header_text)
.block(Block::default().borders(Borders::BOTTOM))
.style(Style::default());
frame.render_widget(header, header_area);
}
fn render_list(frame: &mut Frame, app: &ResultsApp, area: Rect, theme: &Theme) {
let list_area = apply_horizontal_margin(area);
let items: Vec<ListItem> = render_grouped_list(app, list_area, theme);
if items.is_empty() {
let empty_text =
if app.query().filters().is_empty() && app.query().search().query().is_empty() {
"No debt items found"
} else {
"No items match current filters/search"
};
let empty = Paragraph::new(empty_text)
.style(Style::default().fg(theme.muted))
.block(Block::default().borders(Borders::NONE));
frame.render_widget(empty, list_area);
} else {
let list = List::new(items).block(Block::default().borders(Borders::NONE));
frame.render_widget(list, list_area);
}
}
fn render_grouped_list(app: &ResultsApp, area: Rect, theme: &Theme) -> Vec<ListItem<'static>> {
let groups = grouping::group_by_location(app.filtered_items(), app.query().sort_by());
let mut list_items = Vec::new();
for (display_index, group) in groups.iter().skip(app.list().scroll_offset()).enumerate() {
if list_items.len() >= area.height as usize {
break;
}
let is_selected =
(display_index + app.list().scroll_offset()) == app.list().selected_index();
list_items.push(format_grouped_item(
group,
display_index + app.list().scroll_offset(),
is_selected,
theme,
));
}
list_items
}
fn format_grouped_item(
group: &grouping::LocationGroup,
index: usize,
is_selected: bool,
theme: &Theme,
) -> ListItem<'static> {
let severity = Severity::from_score_100(group.combined_score);
let severity_color = severity_to_color(severity);
let indicator = if is_selected { "▸ " } else { " " };
let file_name = group
.location
.file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let badge = if group.items.len() > 1 {
format!(" ({} items)", group.items.len())
} else {
String::new()
};
let metrics = grouping::aggregate_metrics(group);
let mut metric_parts = Vec::new();
if let Some(cov) = metrics.coverage {
metric_parts.push(format!("Cov:{:.0}%", cov.direct * 100.0));
}
if metrics.cognitive_complexity > 0 {
metric_parts.push(format!("Cog:{}", metrics.cognitive_complexity));
}
if metrics.nesting_depth > 0 {
metric_parts.push(format!("Nest:{}", metrics.nesting_depth));
}
if metrics.function_length > 0 {
metric_parts.push(format!("LOC:{}", metrics.function_length));
}
let location_display = if group.location.function == "[file-scope]" {
file_name.to_string()
} else {
format!("{}::{}", file_name, group.location.function)
};
let line = Line::from(vec![
Span::styled(indicator, Style::default().fg(theme.accent())),
Span::styled(
format!("#{:<4}", index + 1),
Style::default().fg(theme.muted),
),
Span::styled(
format!("{:<7.1}", group.combined_score),
Style::default().fg(severity_color),
),
Span::raw(" "),
Span::styled(location_display, Style::default().fg(theme.secondary())),
Span::styled(badge, Style::default().fg(theme.muted)),
Span::raw(" "),
Span::styled(
format!("({})", metric_parts.join(" ")),
Style::default().fg(theme.muted),
),
]);
let style = if is_selected {
Style::default().bg(Color::DarkGray)
} else {
Style::default()
};
ListItem::new(line).style(style)
}
fn render_footer(frame: &mut Frame, app: &ResultsApp, area: Rect, theme: &Theme) {
let position_text = if app.item_count() > 0 {
format!(
"{}/{} items",
app.list().selected_index() + 1,
app.item_count()
)
} else {
"0 items".to_string()
};
let shortcuts_line = Line::from(vec![
Span::styled(position_text, Style::default().fg(theme.muted)),
Span::raw(" | "),
Span::styled("↑↓/jk", Style::default().fg(theme.accent())),
Span::raw(":Nav "),
Span::styled("→/l", Style::default().fg(theme.accent())),
Span::raw(":Details "),
Span::styled("C", Style::default().fg(theme.accent())),
Span::raw(":LLM Copy "),
Span::styled("/", Style::default().fg(theme.accent())),
Span::raw(":Search "),
Span::styled("s", Style::default().fg(theme.accent())),
Span::raw(":Sort "),
Span::styled("f", Style::default().fg(theme.accent())),
Span::raw(":Filter "),
Span::styled("?", Style::default().fg(theme.accent())),
Span::raw(":Help "),
Span::styled("q", Style::default().fg(theme.accent())),
Span::raw(":Quit"),
]);
let lines = if let Some(status) = app.status_message() {
let status_color = if status.starts_with('✓') {
theme.success()
} else {
theme.warning()
};
vec![
Line::from(vec![Span::styled(
status,
Style::default().fg(status_color),
)]),
shortcuts_line,
]
} else {
vec![shortcuts_line]
};
let footer_area = apply_horizontal_margin(area);
let footer = Paragraph::new(lines).block(Block::default().borders(Borders::TOP));
frame.render_widget(footer, footer_area);
}
fn severity_to_color(severity: Severity) -> Color {
match severity {
Severity::Critical => Color::Red,
Severity::High => Color::LightRed,
Severity::Medium => Color::Yellow,
Severity::Low => Color::Green,
}
}