mod severity_badge;
mod tree;
pub use severity_badge::{SeverityBadge, SeverityBar};
pub use tree::{
Tree, TreeNode, TreeState, detect_component_label, detect_component_type, extract_display_name,
};
use crate::tui::theme::colors;
use ratatui::{
prelude::*,
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
};
pub const MASTER_DETAIL_SPLIT: [Constraint; 2] =
[Constraint::Percentage(55), Constraint::Percentage(45)];
pub const FILTER_BAR_HEIGHT: u16 = 3;
pub fn render_scrollbar(frame: &mut ratatui::Frame, area: Rect, total: usize, position: usize) {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.thumb_style(Style::default().fg(colors().accent))
.track_style(Style::default().fg(colors().muted))
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"));
let mut scrollbar_state = ScrollbarState::new(total).position(position);
frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
}
pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> 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(r);
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]
}
pub fn truncate_str(s: &str, max_width: usize) -> String {
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
let display_width = UnicodeWidthStr::width(s);
if display_width <= max_width {
s.to_string()
} else if max_width > 3 {
let mut width = 0;
let truncated: String = s
.chars()
.take_while(|ch| {
let w = UnicodeWidthChar::width(*ch).unwrap_or(0);
if width + w > max_width - 3 {
return false;
}
width += w;
true
})
.collect();
format!("{truncated}...")
} else {
let mut width = 0;
s.chars()
.take_while(|ch| {
let w = UnicodeWidthChar::width(*ch).unwrap_or(0);
if width + w > max_width {
return false;
}
width += w;
true
})
.collect()
}
}
pub fn format_count(count: usize) -> String {
if count >= 1_000_000 {
format!("{:.1}M", count as f64 / 1_000_000.0)
} else if count >= 1_000 {
format!("{:.1}K", count as f64 / 1_000.0)
} else {
count.to_string()
}
}
pub fn render_empty_state_enhanced(
frame: &mut ratatui::Frame,
area: Rect,
icon: &str,
message: &str,
reason: Option<&str>,
action_hint: Option<&str>,
) {
let mut lines = vec![
Line::from(""),
Line::styled(icon, Style::default().fg(colors().text_muted)),
Line::from(""),
Line::styled(message, Style::default().fg(colors().text)),
];
if let Some(r) = reason {
lines.push(Line::from(""));
lines.push(Line::styled(r, Style::default().fg(colors().text_muted)));
}
if let Some(hint) = action_hint {
lines.push(Line::from(""));
lines.push(Line::styled(hint, Style::default().fg(colors().accent)));
}
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(colors().border)),
)
.alignment(Alignment::Center);
frame.render_widget(paragraph, area);
}
pub fn render_no_results_state(
frame: &mut ratatui::Frame,
area: Rect,
filter_name: &str,
filter_value: &str,
) {
let lines = vec![
Line::from(""),
Line::styled("(empty)", Style::default().fg(colors().text_muted)),
Line::from(""),
Line::styled("No results found", Style::default().fg(colors().text)),
Line::from(""),
Line::from(vec![
Span::styled("Filter: ", Style::default().fg(colors().text_muted)),
Span::styled(
format!("{filter_name} = {filter_value}"),
Style::default().fg(colors().accent),
),
]),
Line::from(""),
Line::styled(
"Press [f] to change filter or [Esc] to clear",
Style::default().fg(colors().text_muted),
),
];
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(colors().border)),
)
.alignment(Alignment::Center);
frame.render_widget(paragraph, area);
}
pub fn render_no_results_state_with_hint(
frame: &mut ratatui::Frame,
area: Rect,
filter_name: &str,
filter_value: &str,
hint: &str,
) {
let mut lines = vec![
Line::from(""),
Line::styled("\u{1f50d}", Style::default().fg(colors().text_muted)),
Line::from(""),
Line::styled("No results found", Style::default().fg(colors().text)),
Line::from(""),
Line::from(vec![
Span::styled("Filter: ", Style::default().fg(colors().text_muted)),
Span::styled(
format!("{filter_name} = {filter_value}"),
Style::default().fg(colors().accent),
),
]),
];
if !hint.is_empty() {
lines.push(Line::styled(
hint.to_string(),
Style::default().fg(colors().text_muted),
));
}
lines.push(Line::from(""));
lines.push(Line::styled(
"Press [f] to change filter or [Esc] to clear",
Style::default().fg(colors().text_muted),
));
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(colors().border)),
)
.alignment(Alignment::Center);
frame.render_widget(paragraph, area);
}
pub fn render_mode_indicator(mode: &str) -> Span<'static> {
let (label, color) = match mode.to_lowercase().as_str() {
"diff" => ("DIFF", colors().modified),
"view" => ("VIEW", colors().primary),
"multi-diff" | "multidiff" => ("MULTI", colors().added),
"timeline" => ("TIME", colors().secondary),
"matrix" => ("MATRIX", colors().high),
_ => ("MODE", colors().muted),
};
Span::styled(
format!(" {label} "),
Style::default().fg(colors().badge_fg_dark).bg(color).bold(),
)
}
pub const MIN_WIDTH: u16 = 80;
pub const MIN_HEIGHT: u16 = 24;
pub const fn check_terminal_size(width: u16, height: u16) -> Result<(), (u16, u16)> {
if width < MIN_WIDTH || height < MIN_HEIGHT {
Err((MIN_WIDTH, MIN_HEIGHT))
} else {
Ok(())
}
}
pub fn render_size_warning(
frame: &mut ratatui::Frame,
area: Rect,
required_width: u16,
required_height: u16,
) {
let lines = vec![
Line::styled(
"Terminal too small",
Style::default().fg(colors().warning).bold(),
),
Line::from(""),
Line::from(vec![
Span::raw("Current: "),
Span::styled(
format!("{}x{}", area.width, area.height),
Style::default().fg(colors().text),
),
]),
Line::from(vec![
Span::raw("Required: "),
Span::styled(
format!("{required_width}x{required_height}"),
Style::default().fg(colors().accent),
),
]),
Line::from(""),
Line::styled(
"Please resize your terminal",
Style::default().fg(colors().text_muted),
),
];
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(colors().warning)),
)
.alignment(Alignment::Center);
frame.render_widget(paragraph, area);
}