use super::app::{ViewApp, ViewTab};
use super::views;
use crate::config::TuiPreferences;
use crate::tui::theme::{FooterHints, Theme, 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 crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Clear, Paragraph, Tabs},
};
use std::io::{self, stdout};
use super::events::{Event, EventHandler, handle_key_event, handle_mouse_event};
pub fn run_view_tui(app: &mut ViewApp) -> io::Result<()> {
let prefs = TuiPreferences::load();
set_theme(Theme::from_name(prefs.theme.as_str()));
enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
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;
}
}
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
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),
}
render_status_bar(frame, chunks[3], app);
render_footer(frame, chunks[4], app);
if app.show_help {
let shortcuts_state = crate::tui::app::ShortcutsOverlayState {
visible: true,
context: crate::tui::app::ShortcutsContext::View,
};
crate::tui::views::render_shortcuts_overlay(frame, &shortcuts_state);
}
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 {
Color::Cyan
} 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: &ViewApp) {
let available_tabs = ViewTab::tabs_for_profile(app.bom_profile);
let titles: Vec<Line> = available_tabs
.iter()
.enumerate()
.map(|(i, tab)| {
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)
};
Line::from(vec![
Span::styled(format!("[{}]", i + 1), key_style),
Span::styled(format!(" {} ", tab.title()), title_style),
])
})
.collect();
let selected_idx = available_tabs
.iter()
.position(|t| *t == app.active_tab)
.unwrap_or(0);
let tabs = Tabs::new(titles)
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(colors().border)),
)
.highlight_style(Style::default().fg(colors().accent))
.select(selected_idx)
.divider(Span::styled(" │ ", Style::default().fg(colors().muted)));
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 readiness = metrics.quantum_readiness_score();
let q_color = if readiness >= 80.0 {
Color::Green
} else if readiness >= 40.0 {
Color::Yellow
} else {
Color::Red
};
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)),
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(Color::Red).bold(),
));
}
s
} 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 hints = FooterHints::for_view_tab(tab_name);
let mut footer_spans = render_footer_hints(&hints);
if let Some(yank_text) = super::events::get_yank_text(app) {
let truncated = if yank_text.len() > 30 {
let end = crate::tui::shared::floor_char_boundary(&yank_text, 27);
format!("{}...", &yank_text[..end])
} else {
yank_text
};
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 search_input = Paragraph::new(Line::from(vec![
Span::styled("/", Style::default().fg(colors().primary)),
Span::styled(&search.query, Style::default().fg(colors().text)),
Span::styled(cursor_char, Style::default().fg(colors().accent)),
]))
.block(
Block::default()
.title(format!(
" Search ({} results) [↑↓] select [Enter] go [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, 60, 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(" ✓ ■ ", 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(" © ■ ", 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(" ◐ ■ ", 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(" ⊘ ■ ", Style::default().fg(colors().proprietary)),
Span::styled("Proprietary ", Style::default().fg(colors().text)),
Span::styled("(Commercial)", Style::default().fg(colors().text_muted)),
]),
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);
}