use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use super::graph_renderer::{ACCENT_BLUE, ACCENT_LAVENDER, BG_SURFACE, FG_OVERLAY, FG_TEXT};
pub fn render_package_list(
frame: &mut Frame,
area: Rect,
labels: &[String],
search_query: &str,
scroll_offset: usize,
) {
let block = Block::default()
.title(format!(" Packages ({}) ", labels.len()))
.borders(Borders::ALL)
.border_style(Style::default().fg(ACCENT_BLUE))
.style(Style::default().bg(BG_SURFACE));
let inner = block.inner(area);
frame.render_widget(block, area);
if labels.is_empty() {
let empty = Paragraph::new(" (empty)").style(Style::default().fg(FG_OVERLAY));
frame.render_widget(empty, inner);
return;
}
let max_visible = inner.height as usize;
let query_lower = search_query.to_lowercase();
let mut sorted_labels: Vec<String> = labels.to_vec();
sorted_labels.sort_by_key(|a| a.to_lowercase());
let mut lines: Vec<Line> = Vec::new();
let effective_offset = scroll_offset.min(sorted_labels.len().saturating_sub(1));
let list_height = if sorted_labels.len() > max_visible {
max_visible.saturating_sub(1)
} else {
max_visible
};
for (i, label) in sorted_labels.iter().enumerate().skip(effective_offset) {
if lines.len() >= list_height {
break;
}
let short = truncate_str(label, inner.width.saturating_sub(4) as usize);
let is_match = !query_lower.is_empty() && label.to_lowercase().contains(&query_lower);
let style = if is_match {
Style::default()
.fg(ACCENT_LAVENDER)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(FG_TEXT)
};
let idx_str = format!(" {:>3}.", i + 1);
lines.push(Line::from(vec![
Span::styled(
idx_str,
if is_match {
Style::default().fg(ACCENT_LAVENDER)
} else {
Style::default().fg(FG_OVERLAY)
},
),
Span::styled(short.to_string(), style),
]));
}
if sorted_labels.len() > max_visible {
let visible_end = (effective_offset + list_height).min(sorted_labels.len());
lines.push(Line::from(Span::styled(
format!(
" [{}-{}/{}] [/] scroll",
effective_offset + 1,
visible_end,
sorted_labels.len()
),
Style::default().fg(FG_OVERLAY),
)));
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
}
pub fn truncate_str(s: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
let char_count = s.chars().count();
if char_count <= max_width {
s.to_string()
} else if max_width <= 1 {
"…".to_string()
} else {
let truncated: String = s.chars().take(max_width - 1).collect();
format!("{}…", truncated)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate_str_short() {
assert_eq!(truncate_str("hello", 10), "hello");
}
#[test]
fn test_truncate_str_exact() {
assert_eq!(truncate_str("hello", 5), "hello");
}
#[test]
fn test_truncate_str_long() {
let result = truncate_str("hello_world_module", 10);
assert_eq!(result, "hello_wor…");
assert!(result.len() <= 12); }
#[test]
fn test_truncate_str_zero() {
assert_eq!(truncate_str("hello", 0), "");
}
#[test]
fn test_truncate_str_one() {
assert_eq!(truncate_str("hello", 1), "…");
}
#[test]
fn test_truncate_str_multibyte_emoji() {
let msg = "chore: upgrade → faster 🚀 speed";
assert_eq!(truncate_str(msg, 20), "chore: upgrade → fa…");
}
#[test]
fn test_truncate_str_multibyte_exact() {
let msg = "test→";
assert_eq!(truncate_str(msg, 5), "test→");
assert_eq!(truncate_str(msg, 4), "tes…");
}
}