use super::app::{ViewApp, ViewTab};
use super::views;
use crate::config::TuiPreferences;
use crate::tui::theme::{FooterHints, colors, render_footer_hints, set_theme};
use crate::tui::widgets::{
self, MIN_HEIGHT, MIN_WIDTH, check_terminal_size, render_mode_indicator, render_size_warning,
};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Clear, Paragraph},
};
use std::io::{self, stdout};
use super::events::{Event, EventHandler, handle_key_event, handle_mouse_event};
#[cfg(test)]
mod frame_dump_tests;
#[cfg(test)]
mod render_snapshot_tests;
pub fn run_view_tui(app: &mut ViewApp, no_color: bool) -> io::Result<()> {
let prefs = TuiPreferences::load();
set_theme(crate::tui::theme::startup_theme(
no_color,
prefs.theme.as_str(),
));
crate::tui::shared::install_panic_hook();
let _terminal_guard = crate::tui::shared::TerminalGuard::enter()?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
let events = EventHandler::default();
loop {
terminal.draw(|frame| render(frame, app))?;
match events.next()? {
Event::Key(key) => handle_key_event(app, key),
Event::Mouse(mouse) => {
handle_mouse_event(app, mouse);
}
Event::Resize(_, _) => {}
Event::Tick => {
app.tick += 1;
}
}
if app.should_quit {
break;
}
}
Ok(())
}
fn render(frame: &mut Frame, app: &mut ViewApp) {
let area = frame.area();
if check_terminal_size(area.width, area.height).is_err() {
render_size_warning(frame, area, MIN_WIDTH, MIN_HEIGHT);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Length(3), Constraint::Min(10), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
render_header(frame, chunks[0], app);
render_tabs(frame, chunks[1], app);
match app.active_tab {
ViewTab::Overview => views::render_overview(frame, chunks[2], app),
ViewTab::Tree => views::render_tree(frame, chunks[2], app),
ViewTab::Vulnerabilities => views::render_vulnerabilities(frame, chunks[2], app),
ViewTab::Licenses => views::render_licenses(frame, chunks[2], app),
ViewTab::Dependencies => views::render_dependencies(frame, chunks[2], app),
ViewTab::Quality => views::render_quality(frame, chunks[2], app),
ViewTab::Compliance => views::render_compliance(frame, chunks[2], app),
ViewTab::PqcCompliance => views::render_pqc_compliance(frame, chunks[2], &mut *app),
ViewTab::Source => views::render_source(frame, chunks[2], app),
ViewTab::Crypto => views::render_crypto(frame, chunks[2], app),
ViewTab::Algorithms => views::render_algorithms(frame, chunks[2], app),
ViewTab::Certificates => views::render_certificates(frame, chunks[2], app),
ViewTab::Keys => views::render_keys(frame, chunks[2], app),
ViewTab::Protocols => views::render_protocols(frame, chunks[2], app),
ViewTab::Models => views::render_models(frame, chunks[2], app),
ViewTab::Datasets => views::render_datasets(frame, chunks[2], app),
ViewTab::AiReadiness => views::render_ai_readiness(frame, chunks[2], app),
}
render_status_bar(frame, chunks[3], app);
render_footer(frame, chunks[4], app);
if app.show_help {
let mut shortcuts_state = crate::tui::app::ShortcutsOverlayState {
visible: true,
context: crate::tui::app::ShortcutsContext::View,
profile: Some(app.bom_profile),
tab_title: None,
tab_items: Vec::new(),
scroll: app.help_scroll,
max_scroll: 0,
};
crate::tui::views::render_shortcuts_overlay(frame, &mut shortcuts_state);
app.help_scroll = shortcuts_state.scroll;
app.help_max_scroll = shortcuts_state.max_scroll;
}
if app.search_state.active {
render_search_overlay(frame, area, app);
}
if app.show_export {
let scope = crate::tui::export::view_tab_export_scope(app.active_tab);
crate::tui::shared::export::render_export_dialog(
frame,
area,
scope,
widgets::centered_rect,
);
}
if app.show_legend {
render_legend_overlay(frame, area);
}
}
fn render_header(frame: &mut Frame, area: Rect, app: &ViewApp) {
let name = app
.sbom
.document
.name
.clone()
.unwrap_or_else(|| "SBOM".to_string());
let format_info = format!(
"{} {}",
app.sbom.document.format, app.sbom.document.format_version
);
let profile_color = if app.bom_profile == crate::model::BomProfile::Cbom {
colors().primary
} else {
colors().text_muted
};
let header_line = Line::from(vec![
Span::styled("sbom-tools", Style::default().fg(colors().primary).bold()),
Span::styled(" ", Style::default()),
render_mode_indicator("view"),
Span::styled(" │ ", Style::default().fg(colors().muted)),
Span::styled(&name, Style::default().fg(colors().text).bold()),
Span::styled(" │ ", Style::default().fg(colors().muted)),
Span::styled(format_info, Style::default().fg(colors().text_muted)),
Span::styled(" │ ", Style::default().fg(colors().muted)),
Span::styled(
format!("[{}]", app.bom_profile.label()),
Style::default().fg(profile_color).bold(),
),
]);
let header = Paragraph::new(header_line);
frame.render_widget(header, area);
}
fn render_tabs(frame: &mut Frame, area: Rect, app: &mut ViewApp) {
use unicode_width::UnicodeWidthStr;
let available_tabs = ViewTab::tabs_for_profile(app.bom_profile);
let selected_idx = available_tabs
.iter()
.position(|t| *t == app.active_tab)
.unwrap_or(0);
let widths: Vec<u16> = available_tabs
.iter()
.enumerate()
.map(|(i, tab)| {
UnicodeWidthStr::width(format!("[{}] {} ", i + 1, tab.title()).as_str()) as u16
})
.collect();
let window = crate::tui::shared::tab_window(&widths, 3, selected_idx, area.width);
app.tab_window = window;
let mut spans: Vec<Span> = Vec::new();
if window.clipped_left {
spans.push(Span::styled(
"\u{ab} ",
Style::default().fg(colors().accent).bold(),
));
}
for (rel, (i, tab)) in available_tabs
.iter()
.enumerate()
.skip(window.start)
.take(window.end - window.start)
.enumerate()
{
if rel > 0 {
spans.push(Span::styled(
" \u{2502} ",
Style::default().fg(colors().muted),
));
}
let is_active = *tab == app.active_tab;
let key_style = if is_active {
Style::default().fg(colors().accent).bold()
} else {
Style::default().fg(colors().muted)
};
let title_style = if is_active {
Style::default().fg(colors().accent).bold()
} else {
Style::default().fg(colors().text_muted)
};
spans.push(Span::styled(format!("[{}]", i + 1), key_style));
spans.push(Span::styled(format!(" {} ", tab.title()), title_style));
}
if window.clipped_right {
spans.push(Span::styled(
" \u{bb}",
Style::default().fg(colors().accent).bold(),
));
}
let tabs = Paragraph::new(Line::from(spans)).block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(colors().border)),
);
frame.render_widget(tabs, area);
}
fn render_status_bar(frame: &mut Frame, area: Rect, app: &ViewApp) {
let stats = &app.stats;
let mut spans = if app.bom_profile == crate::model::BomProfile::Cbom {
let metrics = crate::quality::CryptographyMetrics::from_sbom(&app.sbom);
let mut s = vec![
Span::styled(" Algo: ", Style::default().fg(colors().text_muted)),
Span::styled(
metrics.algorithms_count.to_string(),
Style::default().fg(colors().primary).bold(),
),
Span::styled(" │ ", Style::default().fg(colors().muted)),
Span::styled("Certs: ", Style::default().fg(colors().text_muted)),
Span::styled(
metrics.certificates_count.to_string(),
Style::default().fg(colors().primary),
),
Span::styled(" │ ", Style::default().fg(colors().muted)),
Span::styled("Keys: ", Style::default().fg(colors().text_muted)),
Span::styled(
metrics.keys_count.to_string(),
Style::default().fg(colors().primary),
),
Span::styled(" │ ", Style::default().fg(colors().muted)),
Span::styled("Quantum: ", Style::default().fg(colors().text_muted)),
match metrics.quantum_readiness_score() {
None => Span::styled("n/a", Style::default().fg(colors().text_muted)),
Some(readiness) => {
let q_color = if readiness >= 80.0 {
colors().success
} else if readiness >= 40.0 {
colors().warning
} else {
colors().error
};
Span::styled(
format!("{readiness:.0}%"),
Style::default().fg(q_color).bold(),
)
}
},
];
if metrics.weak_algorithm_count > 0 {
s.push(Span::styled(" │ ", Style::default().fg(colors().muted)));
s.push(Span::styled(
format!("Weak: {}", metrics.weak_algorithm_count),
Style::default().fg(colors().critical).bold(),
));
}
s
} else if app.bom_profile == crate::model::BomProfile::AiBom {
vec![
Span::styled(" Components: ", Style::default().fg(colors().text_muted)),
Span::styled(
widgets::format_count(stats.component_count),
Style::default().fg(colors().primary).bold(),
),
Span::styled(" │ ", Style::default().fg(colors().muted)),
Span::styled("Models: ", Style::default().fg(colors().text_muted)),
Span::styled(
app.ml_model_count().to_string(),
Style::default().fg(colors().primary).bold(),
),
Span::styled(" │ ", Style::default().fg(colors().muted)),
Span::styled("Datasets: ", Style::default().fg(colors().text_muted)),
Span::styled(
app.dataset_count().to_string(),
Style::default().fg(colors().primary),
),
]
} else {
let mut s = vec![
Span::styled(" Components: ", Style::default().fg(colors().text_muted)),
Span::styled(
widgets::format_count(stats.component_count),
Style::default().fg(colors().primary).bold(),
),
Span::styled(" │ ", Style::default().fg(colors().muted)),
Span::styled("Vulns: ", Style::default().fg(colors().text_muted)),
];
if stats.vuln_count > 0 {
s.push(Span::styled(
widgets::format_count(stats.vuln_count),
Style::default().fg(colors().error).bold(),
));
if stats.critical_count > 0 {
s.push(Span::styled(
format!(" ({}C", stats.critical_count),
Style::default().fg(colors().critical).bold(),
));
s.push(Span::styled(
format!("/{}H)", stats.high_count),
Style::default().fg(colors().high),
));
}
} else {
s.push(Span::styled("0", Style::default().fg(colors().success)));
}
s.push(Span::styled(" │ ", Style::default().fg(colors().muted)));
s.push(Span::styled(
"Licenses: ",
Style::default().fg(colors().text_muted),
));
s.push(Span::styled(
stats.license_count.to_string(),
Style::default().fg(colors().primary),
));
s
};
if app.active_tab == ViewTab::Tree {
spans.push(Span::styled(" │ ", Style::default().fg(colors().muted)));
spans.push(Span::styled(
"Group: ",
Style::default().fg(colors().text_muted),
));
spans.push(Span::styled(
format!(" {} ", app.tree_group_by.label()),
Style::default()
.fg(colors().badge_fg_dark)
.bg(colors().accent)
.bold(),
));
spans.push(Span::styled(
" Filter: ",
Style::default().fg(colors().text_muted),
));
spans.push(Span::styled(
format!(" {} ", app.tree_filter.label()),
Style::default()
.fg(colors().badge_fg_dark)
.bg(colors().accent)
.bold(),
));
}
if app.navigation_ctx.has_history() {
spans.push(Span::styled(" │ ", Style::default().fg(colors().muted)));
spans.push(Span::styled(
"← ",
Style::default().fg(colors().accent).bold(),
));
spans.push(Span::styled(
app.navigation_ctx.breadcrumb_trail(),
Style::default().fg(colors().text_muted).italic(),
));
spans.push(Span::styled(
" [b] back",
Style::default().fg(colors().accent),
));
}
let status =
Paragraph::new(Line::from(spans)).style(Style::default().bg(colors().background_alt));
frame.render_widget(status, area);
}
fn render_footer(frame: &mut Frame, area: Rect, app: &ViewApp) {
if let Some(ref msg) = app.status_message {
let status_line = Line::from(vec![
Span::styled("ℹ ", Style::default().fg(colors().accent)),
Span::styled(msg.as_str(), Style::default().fg(colors().accent).bold()),
]);
let footer = Paragraph::new(status_line)
.alignment(Alignment::Center)
.style(Style::default());
frame.render_widget(footer, area);
return;
}
let tab_name = app.active_tab.as_str();
let mut hints = FooterHints::for_view_tab(tab_name);
if app.active_tab == ViewTab::Tree
&& !(app.focus_panel == super::app::FocusPanel::Right && app.selected_component.is_some())
{
hints.retain(|(k, _)| *k != "1-4");
}
let hints = hints;
let yank_text = super::events::get_yank_text(app);
let yank_suffix = yank_text.map(|t| {
if t.len() > 30 {
let end = crate::tui::shared::floor_char_boundary(&t, 27);
format!("{}...", &t[..end])
} else {
t
}
});
let yank_width = yank_suffix.as_ref().map_or(0, |t| {
use unicode_width::UnicodeWidthStr;
10 + UnicodeWidthStr::width(t.as_str()) as u16
});
let (mut kept, mut elided) =
crate::tui::theme::fit_footer_hints(&hints, area.width.saturating_sub(yank_width));
let elision_w = if elided { 2 } else { 0 };
let yank_suffix =
if crate::tui::theme::footer_hints_width(&kept) + elision_w + yank_width > area.width {
let refit = crate::tui::theme::fit_footer_hints(&hints, area.width);
kept = refit.0;
elided = refit.1;
None
} else {
yank_suffix
};
let mut footer_spans = render_footer_hints(&kept, elided);
if let Some(truncated) = yank_suffix {
footer_spans.push(Span::styled(" ", Style::default()));
footer_spans.push(Span::styled("[y]", Style::default().fg(colors().accent)));
footer_spans.push(Span::styled(
format!(" copy {truncated}"),
Style::default().fg(colors().text_muted),
));
}
let footer = Paragraph::new(Line::from(footer_spans))
.alignment(Alignment::Center)
.style(Style::default().fg(colors().text_muted));
frame.render_widget(footer, area);
}
fn render_search_overlay(frame: &mut Frame, area: Rect, app: &ViewApp) {
let search = &app.search_state;
let input_area = Rect {
x: area.x + 2,
y: area.height.saturating_sub(4),
width: area.width.saturating_sub(4),
height: 3,
};
frame.render_widget(Clear, input_area);
let cursor_char = "│";
let mode_label = match search.mode {
crate::tui::app_states::SearchMode::Substring => "[substring]",
crate::tui::app_states::SearchMode::Regex => "[regex]",
};
let search_input = Paragraph::new(Line::from(vec![
Span::styled("/ ", Style::default().fg(colors().primary)),
Span::styled(
format!("{mode_label} "),
Style::default().fg(colors().text_muted),
),
Span::styled(&search.query, Style::default().fg(colors().text)),
Span::styled(cursor_char, Style::default().fg(colors().accent)),
if search.search_error.is_some() {
Span::styled(" (invalid pattern)", Style::default().fg(colors().error))
} else {
Span::raw("")
},
]))
.block(
Block::default()
.title(format!(
" Search ({} results) [\u{2191}\u{2193}] select [Enter] go [^R] regex [Esc] cancel ",
search.results.len()
))
.title_style(Style::default().fg(colors().primary))
.borders(Borders::ALL)
.border_style(Style::default().fg(colors().primary)),
);
frame.render_widget(search_input, input_area);
if !search.results.is_empty() {
let results_height = (search.results.len() + 2).min(15) as u16;
let results_area = Rect {
x: area.x + 2,
y: area.height.saturating_sub(4 + results_height),
width: area.width.saturating_sub(4),
height: results_height,
};
frame.render_widget(Clear, results_area);
let mut lines = Vec::new();
for (i, result) in search.results.iter().take(12).enumerate() {
let is_selected = i == search.selected;
let style = if is_selected {
Style::default().bg(colors().selection).bold()
} else {
Style::default()
};
let line = match result {
super::app::SearchResult::Component {
name,
version,
match_field,
..
} => {
let ver = version.as_deref().unwrap_or("");
Line::from(vec![
Span::styled(
if is_selected { "▶ " } else { " " },
Style::default().fg(colors().accent),
),
Span::styled("[C] ", style.fg(colors().primary)),
Span::styled(name, style.fg(colors().text)),
Span::styled(format!("@{ver}"), style.fg(colors().text_muted)),
Span::styled(
format!(" (matched: {match_field})"),
style.fg(colors().text_muted),
),
])
}
super::app::SearchResult::Vulnerability {
id,
component_id: _, component_name,
severity,
} => {
let sev = severity.as_deref().unwrap_or("Unknown");
let sev_color = colors().severity_color(sev);
Line::from(vec![
Span::styled(
if is_selected { "▶ " } else { " " },
Style::default().fg(colors().accent),
),
Span::styled("[V] ", style.fg(colors().high)),
Span::styled(id, style.fg(sev_color).bold()),
Span::styled(format!(" [{sev}]"), style.fg(sev_color)),
Span::styled(
format!(" in {component_name}"),
style.fg(colors().text_muted),
),
])
}
};
lines.push(line);
}
let results = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(colors().primary)),
);
frame.render_widget(results, results_area);
}
}
fn render_legend_overlay(frame: &mut Frame, area: Rect) {
let popup_area = widgets::centered_rect(50, 90, area);
frame.render_widget(Clear, popup_area);
let legend_text = vec![
Line::styled(
"━━━ Color & Symbol Legend ━━━",
Style::default().fg(colors().accent).bold(),
),
Line::from(""),
Line::from(vec![Span::styled(
"Vulnerability Severity",
Style::default().fg(colors().primary).bold(),
)]),
Line::from(vec![
Span::styled(" C ■ ", Style::default().fg(colors().critical)),
Span::styled("Critical ", Style::default().fg(colors().text)),
Span::styled("(CVSS 9.0-10.0)", Style::default().fg(colors().text_muted)),
]),
Line::from(vec![
Span::styled(" H ■ ", Style::default().fg(colors().high)),
Span::styled("High ", Style::default().fg(colors().text)),
Span::styled("(CVSS 7.0-8.9)", Style::default().fg(colors().text_muted)),
]),
Line::from(vec![
Span::styled(" M ■ ", Style::default().fg(colors().medium)),
Span::styled("Medium ", Style::default().fg(colors().text)),
Span::styled("(CVSS 4.0-6.9)", Style::default().fg(colors().text_muted)),
]),
Line::from(vec![
Span::styled(" L ■ ", Style::default().fg(colors().low)),
Span::styled("Low ", Style::default().fg(colors().text)),
Span::styled("(CVSS 0.1-3.9)", Style::default().fg(colors().text_muted)),
]),
Line::from(""),
Line::from(vec![Span::styled(
"License Categories",
Style::default().fg(colors().primary).bold(),
)]),
Line::from(vec![
Span::styled(
format!(
" {} ■ ",
crate::tui::shared::licenses::category_glyph_str("permissive")
),
Style::default().fg(colors().permissive),
),
Span::styled("Permissive ", Style::default().fg(colors().text)),
Span::styled(
"(MIT, Apache, BSD)",
Style::default().fg(colors().text_muted),
),
]),
Line::from(vec![
Span::styled(
format!(
" {} ■ ",
crate::tui::shared::licenses::category_glyph_str("copyleft")
),
Style::default().fg(colors().copyleft),
),
Span::styled("Copyleft ", Style::default().fg(colors().text)),
Span::styled("(GPL, AGPL)", Style::default().fg(colors().text_muted)),
]),
Line::from(vec![
Span::styled(
format!(
" {} ■ ",
crate::tui::shared::licenses::category_glyph_str("weak copyleft")
),
Style::default().fg(colors().weak_copyleft),
),
Span::styled("Weak Copyleft ", Style::default().fg(colors().text)),
Span::styled("(LGPL, MPL)", Style::default().fg(colors().text_muted)),
]),
Line::from(vec![
Span::styled(
format!(
" {} ■ ",
crate::tui::shared::licenses::category_glyph_str("proprietary")
),
Style::default().fg(colors().proprietary),
),
Span::styled("Proprietary ", Style::default().fg(colors().text)),
Span::styled("(Commercial)", Style::default().fg(colors().text_muted)),
]),
Line::from(vec![
Span::styled(" ? ■ ", Style::default().fg(colors().text_muted)),
Span::styled("Unknown ", Style::default().fg(colors().text)),
Span::styled(
"(No license data)",
Style::default().fg(colors().text_muted),
),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Tree Symbols",
Style::default().fg(colors().primary).bold(),
)]),
Line::from(vec![
Span::styled(" ▶ ▼ ", Style::default().fg(colors().accent)),
Span::styled(
"Group collapsed / expanded",
Style::default().fg(colors().text),
),
]),
Line::from(vec![
Span::styled(" · ", Style::default().fg(colors().text_muted)),
Span::styled("Component", Style::default().fg(colors().text)),
]),
Line::from(vec![
Span::styled(" ! ", Style::default().fg(colors().critical)),
Span::styled("Has vulnerabilities", Style::default().fg(colors().text)),
]),
Line::from(vec![
Span::styled(" C 2 H 1 ", Style::default().fg(colors().critical)),
Span::styled(
"Vulnerability count by severity",
Style::default().fg(colors().text),
),
]),
Line::from(""),
Line::styled(
"Press any key to close",
Style::default().fg(colors().text_muted),
),
];
let legend = Paragraph::new(legend_text).block(
Block::default()
.title(" Legend ")
.title_style(Style::default().fg(colors().accent).bold())
.borders(Borders::ALL)
.border_style(Style::default().fg(colors().accent)),
);
frame.render_widget(legend, popup_area);
}