use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use chrono::{DateTime, Local};
use ratada::text::truncate;
use ratatui::Frame;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Cell, Row, Table, TableState};
use unicode_width::UnicodeWidthStr;
use crate::config::{ColumnWidth, Config};
use crate::domain::filter::{Tab, TabKind};
use crate::domain::repo::{GitInfo, Repo};
use crate::domain::sections::UNGROUPED;
use crate::domain::stats::{CodeEntry, GitStats};
use crate::theme::Skin;
use crate::tui::columns::{ColumnSet, StatColumn, stat_columns};
use crate::tui::git_columns::{
branch_text, effective_info, github_text, status_display, zip_date_text,
};
use crate::tui::list_layout::{
ListScroll, SLUG_HEADER, settled_offset, slug_column_width,
};
use crate::tui::presentation::{
IconSet, highlight_name, slug_style, status_span,
};
use crate::tui::row_cells::{
GlyphContext, StatContext, fav_span, marker_span, type_label,
};
use crate::tui::skin::Colors;
pub struct TableView<'a> {
pub tab: Tab,
pub config: &'a Config,
pub skin: &'a Skin,
pub colors: &'a Colors,
pub icons: &'a IconSet,
pub example_mode: bool,
pub spinner: Option<(&'a HashSet<PathBuf>, &'a str)>,
pub selected: &'a [bool],
pub has_selection: bool,
pub missing: &'a HashSet<PathBuf>,
pub show_slugs: bool,
pub show_section: bool,
pub query: Option<&'a str>,
pub zip_backups: &'a HashMap<PathBuf, DateTime<Local>>,
pub offset: &'a std::cell::Cell<usize>,
pub columns: ColumnSet,
pub code: &'a HashMap<PathBuf, CodeEntry>,
pub git: &'a HashMap<PathBuf, GitStats>,
pub computing: &'a HashSet<PathBuf>,
pub now: i64,
}
impl TableView<'_> {
fn stats(&self) -> StatContext<'_> {
StatContext {
code: self.code,
git: self.git,
computing: self.computing,
spinner: self.spinner,
now: self.now,
}
}
fn glyphs(&self) -> GlyphContext<'_> {
GlyphContext {
example_mode: self.example_mode,
missing: self.missing,
icons: self.icons,
colors: self.colors,
}
}
fn stat_text(&self, repo: &Repo, column: StatColumn) -> String {
self.stats().text(repo, column)
}
}
fn name_cell_spans(repo: &Repo, view: &TableView) -> Vec<Span<'static>> {
let name = repo.display_name();
match view.query {
Some(query) if !query.trim().is_empty() => {
highlight_name(&name, query, view.colors)
}
_ => vec![Span::raw(name)],
}
}
pub fn render_table(
frame: &mut Frame,
area: Rect,
repos: &[&Repo],
cursor: usize,
view: &TableView,
) {
let viewport = area.height.saturating_sub(1) as usize; let overflow = viewport > 0 && repos.len() > viewport;
let table_area = if overflow {
Rect {
width: area.width.saturating_sub(1),
..area
}
} else {
area
};
let rows: Vec<Row> = repos
.iter()
.enumerate()
.map(|(row, repo)| {
let selected = view.selected.get(row).copied().unwrap_or(false);
let built = row_for(repo, view, selected);
if selected {
built.style(Style::default().bg(view.colors.multi_select_bg))
} else {
built
}
})
.collect();
let table = Table::new(rows, widths(repos, view, table_area.width))
.header(header_row(view))
.column_spacing(1)
.row_highlight_style(view.colors.selection_style());
let offset = settled_offset(&ListScroll {
saved: view.offset.get(),
cursor,
row_count: repos.len(),
viewport,
first_entry_row: None,
});
view.offset.set(offset);
let mut state = TableState::default()
.with_offset(offset)
.with_selected(Some(cursor));
frame.render_stateful_widget(table, table_area, &mut state);
if overflow {
let bar_area = Rect {
x: area.x,
y: area.y + 1,
width: area.width,
height: area.height.saturating_sub(1),
};
ratada::scroll::render_scrollbar(
frame,
bar_area,
view.skin,
ratada::nav::ScrollView {
total: repos.len(),
offset,
viewport,
},
);
}
}
fn widths(
repos: &[&Repo],
view: &TableView,
available: u16,
) -> Vec<Constraint> {
let cols = &view.config.column_widths;
let name = sized(content_width(repos, |r| r.display_name()), 4, cols.name);
let mut cells: Vec<Constraint> = if view.has_selection {
vec![
Constraint::Length(2),
Constraint::Length(1),
Constraint::Length(1),
]
} else {
vec![Constraint::Length(1), Constraint::Length(1)]
};
if view.show_section {
cells.push(Constraint::Length(section_width(repos)));
}
cells.push(Constraint::Length(name));
if view.show_slugs {
cells.push(Constraint::Length(slug_width(repos)));
}
if view.columns.is_statistics() {
cells.extend(
stat_columns(view.columns)
.iter()
.map(|column| Constraint::Length(column.width())),
);
cells.push(Constraint::Min(0));
return cells;
}
match view.tab.kind() {
TabKind::Files => {
cells.push(Constraint::Length(6));
cells.push(Constraint::Min(20));
}
TabKind::Git => {
let branch = sized(
content_width(repos, |r| {
branch_text(effective_info(r, view.example_mode))
}),
6,
cols.current_branch_name,
);
let status = sized(
content_width(repos, |r| {
status_display(
effective_info(r, view.example_mode),
view.icons,
)
}),
6,
cols.status,
);
let github_desired = sized(
content_width(repos, |r| {
github_text(effective_info(r, view.example_mode))
}),
6,
cols.github_repo_name,
);
let zip = sized(
content_width(repos, |r| {
zip_date_text(r, view.icons, view.zip_backups)
}),
"ZIP Backup".len(),
cols.zip_backup,
);
let section = if view.show_section {
section_width(repos)
} else {
0
};
let slug = if view.show_slugs {
slug_width(repos)
} else {
0
};
let lead_cells: u16 = (if view.has_selection { 3 } else { 2 })
+ section_cells(view)
+ slug_cells(view);
let lead_width: u16 =
(if view.has_selection { 4 } else { 2 }) + section + slug;
let spacing = (lead_cells + 5).saturating_sub(1); let fixed = lead_width + name + branch + status + zip + spacing;
let github = github_width(github_desired, fixed, available);
cells.push(Constraint::Length(branch));
cells.push(Constraint::Length(status));
cells.push(Constraint::Length(github));
cells.push(Constraint::Length(zip));
}
}
cells
}
fn content_width<F>(repos: &[&Repo], cell: F) -> usize
where
F: Fn(&Repo) -> String,
{
repos
.iter()
.map(|repo| UnicodeWidthStr::width(cell(repo).as_str()))
.max()
.unwrap_or(0)
}
fn sized(content: usize, header: usize, width: ColumnWidth) -> u16 {
let floor = width.min.max(header);
let mut chosen = content.max(floor);
if let Some(max) = width.max {
chosen = chosen.min(max.max(floor));
}
chosen as u16
}
fn github_width(desired: u16, fixed: u16, available: u16) -> u16 {
desired.min(available.saturating_sub(fixed))
}
const SECTION_HEADER: &str = "Section";
const SECTION_MAX: usize = 24;
fn section_width(repos: &[&Repo]) -> u16 {
content_width(repos, section_label)
.max(SECTION_HEADER.len())
.min(SECTION_MAX) as u16
}
fn section_cells(view: &TableView) -> u16 {
u16::from(view.show_section)
}
fn section_label(repo: &Repo) -> String {
repo.section
.clone()
.unwrap_or_else(|| UNGROUPED.to_string())
}
fn slug_width(repos: &[&Repo]) -> u16 {
slug_column_width(content_width(repos, slug_text)) as u16
}
fn slug_cells(view: &TableView) -> u16 {
u16::from(view.show_slugs)
}
fn slug_text(repo: &Repo) -> String {
repo.slug.clone().unwrap_or_default()
}
fn header_row(view: &TableView) -> Row<'static> {
let titles: Vec<String> = if view.columns.is_statistics() {
let mut titles = vec![String::new(), String::new(), "Name".to_string()];
titles.extend(
stat_columns(view.columns)
.iter()
.map(|column| column.title().to_string()),
);
titles.push(String::new());
titles
} else {
match view.tab.kind() {
TabKind::Files => vec!["", "", "Name", "Type", "Path"],
TabKind::Git => {
vec!["", "", "Name", "Branch", "Status", "GitHub", "ZIP Backup"]
}
}
.into_iter()
.map(str::to_string)
.collect()
};
let mut cells: Vec<Cell> = Vec::new();
if view.has_selection {
cells.push(Cell::from(""));
}
let mut titles = titles;
if view.show_slugs {
titles.insert(3, SLUG_HEADER.to_string());
}
if view.show_section {
titles.insert(2, SECTION_HEADER.to_string());
}
cells.extend(titles.into_iter().map(Cell::from));
Row::new(cells).style(view.colors.header_style())
}
fn row_for<'a>(repo: &Repo, view: &TableView, selected: bool) -> Row<'a> {
let mut cells: Vec<Cell> = Vec::new();
if view.has_selection {
cells.push(selection_cell(selected, view.colors));
}
cells.push(marker_cell(repo, view));
cells.push(fav_cell(repo, view));
if view.show_section {
cells.push(Cell::from(Span::styled(
truncate(§ion_label(repo), SECTION_MAX),
Style::default().fg(view.colors.dim),
)));
}
cells.push(Cell::from(Line::from(name_cell_spans(repo, view))));
if view.show_slugs {
cells.push(Cell::from(Span::styled(
slug_text(repo),
slug_style(view.colors),
)));
}
if view.columns.is_statistics() {
for column in stat_columns(view.columns) {
let text = view.stat_text(repo, *column);
let cell = if column.is_numeric() {
Cell::from(Line::from(text).right_aligned())
} else {
Cell::from(text)
};
cells.push(cell);
}
cells.push(Cell::from(""));
return Row::new(cells);
}
match view.tab.kind() {
TabKind::Files => {
cells.push(Cell::from(type_label(repo)));
cells.push(Cell::from(Span::styled(
repo.path.to_string_lossy().into_owned(),
Style::default().fg(view.colors.dim),
)));
}
TabKind::Git => {
let info = effective_info(repo, view.example_mode);
let branch_cap = view
.config
.column_widths
.current_branch_name
.max
.unwrap_or(usize::MAX);
cells.push(Cell::from(truncate(&branch_text(info), branch_cap)));
cells.push(status_cell(repo, info, view));
cells.push(Cell::from(github_text(info)));
cells.push(Cell::from(Span::styled(
zip_date_text(repo, view.icons, view.zip_backups),
Style::default().fg(view.colors.dim),
)));
}
}
Row::new(cells)
}
fn selection_cell<'a>(selected: bool, colors: &Colors) -> Cell<'a> {
let symbol = if selected { "\u{25b8} " } else { " " };
Cell::from(Span::styled(
symbol,
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD),
))
}
fn marker_cell<'a>(repo: &Repo, view: &TableView) -> Cell<'a> {
Cell::from(marker_span(repo, &view.glyphs()))
}
fn fav_cell<'a>(repo: &Repo, view: &TableView) -> Cell<'a> {
Cell::from(fav_span(repo, &view.glyphs()))
}
fn status_cell<'a>(
repo: &Repo,
info: Option<&GitInfo>,
view: &TableView,
) -> Cell<'a> {
if let Some((in_flight, glyph)) = view.spinner
&& in_flight.contains(&repo.path)
{
return Cell::from(Span::styled(
glyph.to_string(),
Style::default().fg(view.colors.accent),
));
}
Cell::from(status_span(info, view.icons, view.colors))
}
#[cfg(test)]
mod tests {
use super::github_width;
#[test]
fn github_keeps_content_width_when_there_is_room() {
assert_eq!(github_width(20, 60, 200), 20);
}
#[test]
fn github_yields_width_when_tight() {
assert_eq!(github_width(39, 64, 90), 26);
}
#[test]
fn github_collapses_rather_than_overflowing() {
assert_eq!(github_width(39, 95, 90), 0);
}
}