use std::collections::HashSet;
use ratatui::{
buffer::Buffer,
layout::Rect,
text::{Line, Span},
widgets::Widget,
};
use crate::package::Script;
use crate::tui::layout::{calculate_column_width, calculate_columns};
use crate::tui::theme::Theme;
use crate::tui::widgets::header::truncate_with_ellipsis;
pub struct ScriptsGrid<'a> {
scripts: &'a [&'a Script],
selected: usize,
scroll_offset: usize,
theme: &'a Theme,
multi_selected: Option<&'a HashSet<usize>>,
}
impl<'a> ScriptsGrid<'a> {
pub fn new(scripts: &'a [&'a Script], selected: usize, theme: &'a Theme) -> Self {
Self {
scripts,
selected,
scroll_offset: 0,
theme,
multi_selected: None,
}
}
pub fn scroll_offset(mut self, offset: usize) -> Self {
self.scroll_offset = offset;
self
}
pub fn multi_selected(mut self, selected: &'a HashSet<usize>) -> Self {
self.multi_selected = Some(selected);
self
}
fn render_script(
&self,
script: &Script,
index: usize,
is_selected: bool,
is_multi_selected: bool,
max_width: u16,
) -> Vec<Span<'a>> {
let num_str = if index < 9 {
format!("{}", index + 1)
} else {
" ".to_string()
};
let marker = if is_selected {
">"
} else if is_multi_selected {
"*"
} else {
" "
};
let prefix_len = 4; let name_width = (max_width as usize).saturating_sub(prefix_len);
let name = truncate_with_ellipsis(script.name(), name_width);
let marker_style = if is_multi_selected {
self.theme.multiselect()
} else {
self.theme.cursor()
};
let name_style = if is_selected {
self.theme.selected()
} else {
self.theme.script()
};
vec![
Span::styled(format!("{} ", num_str), self.theme.number()),
Span::styled(format!("{} ", marker), marker_style),
Span::styled(name, name_style),
]
}
}
impl Widget for ScriptsGrid<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 || self.scripts.is_empty() {
return;
}
let columns = calculate_columns(area.width);
let column_width = calculate_column_width(area.width, columns);
let rows = area.height as usize;
let total_visible = rows * columns;
let start_idx = self.scroll_offset;
let end_idx = (start_idx + total_visible).min(self.scripts.len());
for display_idx in 0..(end_idx - start_idx) {
let script_idx = start_idx + display_idx;
let row = display_idx / columns;
let col = display_idx % columns;
if row >= rows {
break;
}
let x = area.x + (col as u16 * column_width);
let y = area.y + row as u16;
if y >= area.y + area.height {
break;
}
let script = self.scripts[script_idx];
let is_selected = script_idx == self.selected;
let is_multi = self
.multi_selected
.map(|m| m.contains(&script_idx))
.unwrap_or(false);
let spans =
self.render_script(script, display_idx, is_selected, is_multi, column_width);
let line = Line::from(spans);
let item_area = Rect::new(x, y, column_width, 1);
buf.set_line(item_area.x, item_area.y, &line, item_area.width);
}
}
}
pub struct EmptyScripts<'a> {
message: &'a str,
hint: Option<&'a str>,
theme: &'a Theme,
}
impl<'a> EmptyScripts<'a> {
pub fn new(message: &'a str, theme: &'a Theme) -> Self {
Self {
message,
hint: None,
theme,
}
}
pub fn with_hint(message: &'a str, hint: &'a str, theme: &'a Theme) -> Self {
Self {
message,
hint: Some(hint),
theme,
}
}
pub fn no_scripts(theme: &'a Theme) -> Self {
Self::with_hint(
"No scripts found in package.json",
"Add scripts to your package.json to get started",
theme,
)
}
pub fn no_matches(theme: &'a Theme) -> Self {
Self::with_hint(
"No scripts match the filter",
"Press Escape to clear the filter",
theme,
)
}
pub fn no_matches_for(filter: &str, theme: &'a Theme) -> Self {
let _ = filter;
Self::no_matches(theme)
}
pub fn all_excluded(theme: &'a Theme) -> Self {
Self::with_hint(
"All scripts are excluded",
"Check your exclude patterns in config",
theme,
)
}
}
impl Widget for EmptyScripts<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let has_hint = self.hint.is_some() && area.height >= 3;
let y = if has_hint {
area.y + area.height / 2 - 1
} else {
area.y + area.height / 2
};
if y >= area.y + area.height {
return;
}
let msg_len = self.message.chars().count() as u16;
let x = area.x + (area.width.saturating_sub(msg_len)) / 2;
let line = Line::from(vec![Span::styled(self.message, self.theme.description())]);
buf.set_line(x, y, &line, area.width.saturating_sub(x - area.x));
if let Some(hint) = self.hint {
let hint_y = y + 2;
if hint_y < area.y + area.height {
let hint_len = hint.chars().count() as u16;
let hint_x = area.x + (area.width.saturating_sub(hint_len)) / 2;
let hint_line =
Line::from(vec![Span::styled(hint, self.theme.filter_placeholder())]);
buf.set_line(
hint_x,
hint_y,
&hint_line,
area.width.saturating_sub(hint_x - area.x),
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_scripts() -> Vec<Script> {
vec![
Script::new("dev", "vite"),
Script::new("build", "vite build"),
Script::new("test", "vitest"),
Script::new("lint", "eslint ."),
Script::new("format", "prettier --write ."),
]
}
#[test]
fn test_scripts_grid_creation() {
let scripts = create_test_scripts();
let script_refs: Vec<&Script> = scripts.iter().collect();
let theme = Theme::default();
let grid = ScriptsGrid::new(&script_refs, 0, &theme);
assert_eq!(grid.selected, 0);
assert_eq!(grid.scroll_offset, 0);
}
#[test]
fn test_render_script() {
let scripts = create_test_scripts();
let script_refs: Vec<&Script> = scripts.iter().collect();
let theme = Theme::default();
let grid = ScriptsGrid::new(&script_refs, 0, &theme);
let spans = grid.render_script(&scripts[0], 0, true, false, 30);
let content: String = spans.iter().map(|s| s.content.to_string()).collect();
assert!(content.contains("dev"));
assert!(content.contains("1")); }
#[test]
fn test_render_script_multiselect() {
let scripts = create_test_scripts();
let script_refs: Vec<&Script> = scripts.iter().collect();
let theme = Theme::default();
let grid = ScriptsGrid::new(&script_refs, 0, &theme);
let spans = grid.render_script(&scripts[0], 0, false, true, 30);
let content: String = spans.iter().map(|s| s.content.to_string()).collect();
assert!(content.contains("*")); }
#[test]
fn test_calculate_columns() {
assert_eq!(calculate_columns(50), 1);
assert_eq!(calculate_columns(80), 2);
assert_eq!(calculate_columns(100), 3);
assert_eq!(calculate_columns(130), 4);
assert_eq!(calculate_columns(170), 5);
}
}