use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use chrono::{DateTime, Local};
use ratada::text::truncate;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
use unicode_width::UnicodeWidthStr;
use crate::domain::repo::{Repo, is_dir_target};
use crate::domain::sections::SectionGroup;
use crate::domain::stats::{CodeEntry, GitStats};
use crate::theme::Skin;
use crate::tui::columns::{
CellSource, ColumnSet, StatColumn, cell_text, stat_columns,
};
use crate::tui::presentation::{IconSet, name_plain, slug_style};
use crate::tui::skin::Colors;
const NAME_MIN: usize = 4;
const NAME_MAX: usize = 30;
const TYPE_WIDTH: usize = 6;
const ZIP_WIDTH: usize = 10;
pub struct SectionedView<'a> {
pub groups: &'a [SectionGroup],
pub repos: &'a [Repo],
pub icons: &'a IconSet,
pub skin: &'a Skin,
pub colors: &'a Colors,
pub selected: &'a HashSet<usize>,
pub has_selection: bool,
pub missing: &'a HashSet<PathBuf>,
pub show_slugs: bool,
pub zip_backups: &'a HashMap<PathBuf, DateTime<Local>>,
pub offset: &'a Cell<usize>,
pub columns: ColumnSet,
pub code: &'a HashMap<PathBuf, CodeEntry>,
pub git: &'a HashMap<PathBuf, GitStats>,
pub computing: &'a HashSet<PathBuf>,
pub spinner: Option<&'a str>,
pub now: i64,
}
impl SectionedView<'_> {
fn stat_text(&self, repo: &Repo, column: StatColumn) -> String {
let source = CellSource {
code: self.code.get(&repo.path),
git: self.git.get(&repo.path),
open_count: repo.open_count,
last_used: repo.last_used,
pending: self.computing.contains(&repo.path),
now: self.now,
};
cell_text(column, source)
.unwrap_or_else(|| self.spinner.unwrap_or("-").to_string())
}
}
pub fn render(
frame: &mut Frame,
area: Rect,
cursor: usize,
view: &SectionedView,
) {
let header_rows: u16 = u16::from(view.columns.is_statistics());
let area = if header_rows > 0 && area.height > header_rows {
render_column_header(frame, area, view);
Rect {
y: area.y + header_rows,
height: area.height - header_rows,
..area
}
} else {
area
};
let row_count = view.groups.len() + entry_count(view.groups);
let viewport = area.height as usize;
let overflow = viewport > 0 && row_count > viewport;
let list_area = if overflow {
Rect {
width: area.width.saturating_sub(1),
..area
}
} else {
area
};
let content_width = list_area.width as usize;
let (items, cursor_row, first_entry_row) =
build_items(view, cursor, content_width);
let offset = settled_offset(
view.offset.get(),
cursor_row,
first_entry_row,
row_count,
viewport,
);
view.offset.set(offset);
let list = List::new(items).highlight_style(view.colors.selection_style());
let mut state = ListState::default();
*state.offset_mut() = offset;
state.select(Some(cursor_row));
frame.render_stateful_widget(list, list_area, &mut state);
if overflow {
ratada::scroll::render_scrollbar(
frame,
area,
view.skin,
ratada::nav::ScrollView {
total: row_count,
offset,
viewport,
},
);
}
}
fn entry_count(groups: &[SectionGroup]) -> usize {
groups.iter().map(|group| group.items.len()).sum()
}
fn build_items<'a>(
view: &SectionedView,
cursor: usize,
width: usize,
) -> (Vec<ListItem<'a>>, usize, usize) {
let name_width = name_width(view);
let mut items: Vec<ListItem> = Vec::new();
let mut cursor_row = 0;
let mut first_entry_row = 0;
let mut entry_pos = 0;
let mut seen_entry = false;
for group in view.groups {
items.push(header_item(&group.label, width, view.colors));
for &index in &group.items {
let row = items.len();
if !seen_entry {
first_entry_row = row;
seen_entry = true;
}
if entry_pos == cursor {
cursor_row = row;
}
items.push(entry_item(view, index, name_width, width));
entry_pos += 1;
}
}
(items, cursor_row, first_entry_row)
}
fn settled_offset(
saved: usize,
cursor_row: usize,
first_entry_row: usize,
row_count: usize,
viewport: usize,
) -> usize {
let mut offset = saved.min(row_count.saturating_sub(1));
if cursor_row < offset {
offset = cursor_row;
} else if viewport > 0 && cursor_row >= offset + viewport {
offset = cursor_row + 1 - viewport;
}
if offset <= first_entry_row {
offset = 0;
}
offset
}
fn render_column_header(frame: &mut Frame, area: Rect, view: &SectionedView) {
let row = Rect { height: 1, ..area };
let name_width = name_width(view);
let mut spans = vec![Span::raw(" ")];
spans.push(Span::raw(pad("Name", name_width)));
for column in stat_columns(view.columns) {
spans.push(Span::raw(" "));
spans.push(Span::raw(format!(
"{:>width$}",
column.title(),
width = column.width() as usize
)));
}
frame.render_widget(
Paragraph::new(Line::from(spans)).style(view.colors.header_style()),
row,
);
}
fn stat_spans(view: &SectionedView, repo: &Repo) -> Vec<Span<'static>> {
let mut spans = Vec::new();
for column in stat_columns(view.columns) {
let text = view.stat_text(repo, *column);
let width = column.width() as usize;
let cell = if column.is_numeric() {
format!("{text:>width$}")
} else {
pad(&text, width)
};
spans.push(Span::raw(" "));
spans.push(Span::styled(
cell,
Style::default().fg(view.colors.foreground),
));
}
spans
}
fn header_item<'a>(label: &str, width: usize, colors: &Colors) -> ListItem<'a> {
let title = format!(" {label} ");
let used = UnicodeWidthStr::width(title.as_str());
let rule = "\u{2500}".repeat(width.saturating_sub(used));
ListItem::new(Line::from(vec![
Span::styled(
title,
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(rule, Style::default().fg(colors.dim)),
]))
}
fn entry_item<'a>(
view: &SectionedView,
index: usize,
name_width: usize,
width: usize,
) -> ListItem<'a> {
let repo = &view.repos[index];
let selected = view.selected.contains(&index);
let lead = if view.has_selection && selected {
Span::styled(
"\u{25b8} ",
Style::default()
.fg(view.colors.accent)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
};
let name_field = name_field_spans(
&repo.display_name(),
shown_slug(view, repo),
name_width,
view.colors,
);
if view.columns.is_statistics() {
let mut spans = vec![
lead,
marker_span(repo, view),
fav_span(repo, view),
Span::raw(" "),
];
spans.extend(name_field);
spans.extend(stat_spans(view, repo));
let item = ListItem::new(Line::from(spans));
return if selected {
item.style(Style::default().bg(view.colors.multi_select_bg))
} else {
item
};
}
let kind = pad(type_label(repo), TYPE_WIDTH);
let used = 2 + 1 + 1 + 1 + name_width + 2 + TYPE_WIDTH + 2 + 2 + ZIP_WIDTH;
let path = pad(&repo.path.to_string_lossy(), width.saturating_sub(used));
let zip = format!("{:>ZIP_WIDTH$}", zip_cell_text(repo, view));
let mut spans = vec![
lead,
marker_span(repo, view),
fav_span(repo, view),
Span::raw(" "),
];
spans.extend(name_field);
spans.extend([
Span::raw(" "),
Span::raw(kind),
Span::raw(" "),
Span::styled(path, Style::default().fg(view.colors.dim)),
Span::raw(" "),
Span::styled(zip, Style::default().fg(view.colors.dim)),
]);
let item = ListItem::new(Line::from(spans));
if selected {
item.style(Style::default().bg(view.colors.multi_select_bg))
} else {
item
}
}
fn marker_span(repo: &Repo, view: &SectionedView) -> Span<'static> {
if view.missing.contains(&repo.path) {
Span::styled(
view.icons.missing.to_string(),
Style::default()
.fg(view.colors.danger)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
}
}
fn fav_span(repo: &Repo, view: &SectionedView) -> Span<'static> {
if repo.fav {
Span::styled(
view.icons.favourite.to_string(),
Style::default().fg(view.colors.favourite),
)
} else {
Span::raw(" ")
}
}
fn name_width(view: &SectionedView) -> usize {
view.groups
.iter()
.flat_map(|group| group.items.iter())
.map(|&index| {
let repo = &view.repos[index];
UnicodeWidthStr::width(
name_plain(&repo.display_name(), shown_slug(view, repo))
.as_str(),
)
})
.max()
.unwrap_or(NAME_MIN)
.clamp(NAME_MIN, NAME_MAX)
}
fn shown_slug<'a>(view: &SectionedView, repo: &'a Repo) -> Option<&'a str> {
repo.slug.as_deref().filter(|_| view.show_slugs)
}
fn name_field_spans(
name: &str,
slug: Option<&str>,
width: usize,
colors: &Colors,
) -> Vec<Span<'static>> {
let Some(slug) = slug else {
return vec![Span::raw(pad(name, width))];
};
let name_w = UnicodeWidthStr::width(name);
if width <= name_w + 1 {
return vec![Span::raw(pad(name, width))];
}
let slug_text = truncate(slug, width - name_w - 1);
let used = name_w + 1 + UnicodeWidthStr::width(slug_text.as_str());
vec![
Span::raw(name.to_string()),
Span::styled(format!(" {slug_text}"), slug_style(colors)),
Span::raw(" ".repeat(width.saturating_sub(used))),
]
}
fn pad(text: &str, width: usize) -> String {
let len = UnicodeWidthStr::width(text);
if len >= width {
truncate(text, width)
} else {
format!("{text}{}", " ".repeat(width - len))
}
}
fn zip_cell_text(repo: &Repo, view: &SectionedView) -> String {
if !repo.include_in_backup {
return view.icons.excluded.to_string();
}
view.zip_backups
.get(&repo.path)
.map_or_else(|| "-".to_string(), |dt| dt.format("%Y-%m-%d").to_string())
}
fn type_label(repo: &Repo) -> &'static str {
if is_dir_target(&repo.path) {
"folder"
} else {
"file"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settled_offset_pages_and_snaps() {
assert_eq!(settled_offset(0, 1, 1, 10, 5), 0);
assert_eq!(settled_offset(0, 8, 1, 20, 5), 4);
assert_eq!(settled_offset(6, 2, 1, 20, 5), 2);
assert_eq!(settled_offset(1, 1, 2, 10, 5), 0);
}
#[test]
fn pad_fills_or_truncates() {
assert_eq!(pad("ab", 5), "ab ");
assert_eq!(pad("abcdef", 4), "abc…");
}
#[test]
fn name_field_spans_adds_slug_only_with_room() {
let colors =
Colors::from_palette(&crate::config::Config::default().palette());
assert_eq!(name_field_spans("hop", None, 10, &colors).len(), 1);
assert_eq!(name_field_spans("hop", Some("hp"), 12, &colors).len(), 3);
assert_eq!(name_field_spans("hop", Some("hp"), 4, &colors).len(), 1);
}
}