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::config::Config;
use crate::domain::filter::{Tab, TabKind};
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::git_columns::{
branch_text, effective_info, git_marker_errored, github_text,
status_display, zip_date_text,
};
use crate::tui::presentation::{IconSet, slug_style, status_span};
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;
const BRANCH_MIN: usize = 6;
const BRANCH_MAX: usize = 20;
const STATUS_MIN: usize = 6;
const STATUS_MAX: usize = 12;
const SLUG_HEADER: &str = "Slug";
const SLUG_MAX: usize = 20;
pub struct SectionedView<'a> {
pub tab: Tab,
pub groups: &'a [SectionGroup],
pub repos: &'a [Repo],
pub config: &'a Config,
pub example_mode: bool,
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 HashSet<PathBuf>, &'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_glyph().to_string())
}
fn spinner_glyph(&self) -> &str {
self.spinner.map_or("-", |(_, glyph)| glyph)
}
fn is_in_flight(&self, repo: &Repo) -> bool {
self.spinner
.is_some_and(|(in_flight, _)| in_flight.contains(&repo.path))
}
}
pub fn render(
frame: &mut Frame,
area: Rect,
cursor: usize,
view: &SectionedView,
) {
let header_h: u16 = if area.height > 1 { 1 } else { 0 };
let header_area = (header_h > 0).then_some(Rect { height: 1, ..area });
let list_full = Rect {
y: area.y + header_h,
height: area.height - header_h,
..area
};
let row_count = view.groups.len() + entry_count(view.groups);
let viewport = list_full.height as usize;
let overflow = viewport > 0 && row_count > viewport;
let list_area = if overflow {
Rect {
width: list_full.width.saturating_sub(1),
..list_full
}
} else {
list_full
};
let content_width = list_area.width as usize;
if let Some(header_area) = header_area {
render_column_header(frame, header_area, view, content_width);
}
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,
list_full,
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,
width: usize,
) {
let row = Rect { height: 1, ..area };
frame.render_widget(
Paragraph::new(Line::from(header_spans(view, width)))
.style(view.colors.header_style()),
row,
);
}
fn header_spans(view: &SectionedView, width: usize) -> Vec<Span<'static>> {
let name_w = name_width(view);
let mut spans = vec![Span::raw(" "), Span::raw(pad("Name", name_w))];
if view.show_slugs {
spans.push(Span::raw(" "));
spans.push(Span::raw(pad(SLUG_HEADER, slug_col_width(view))));
}
if view.columns.is_statistics() {
for column in stat_columns(view.columns) {
spans.push(Span::raw(" "));
spans.push(Span::raw(format!(
"{:>w$}",
column.title(),
w = column.width() as usize
)));
}
return spans;
}
let prefix = content_prefix(view, name_w);
match view.tab.kind() {
TabKind::Files => {
spans.push(Span::raw(" "));
spans.push(Span::raw(pad("Type", TYPE_WIDTH)));
spans.push(Span::raw(" "));
spans.push(Span::raw(pad("Path", files_path_width(prefix, width))));
}
TabKind::Git => {
let (branch_w, status_w, github_w) =
git_column_widths(view, prefix, width);
spans.push(Span::raw(" "));
spans.push(Span::raw(pad("Branch", branch_w)));
spans.push(Span::raw(" "));
spans.push(Span::raw(pad("Status", status_w)));
spans.push(Span::raw(" "));
spans.push(Span::raw(pad("GitHub", github_w)));
}
}
spans.push(Span::raw(" "));
spans.push(Span::raw(format!("{:>ZIP_WIDTH$}", "ZIP Backup")));
spans
}
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(), name_width);
let mut spans = vec![
lead,
marker_span(repo, view),
fav_span(repo, view),
Span::raw(" "),
];
spans.extend(name_field);
if view.show_slugs {
spans.push(Span::raw(" "));
spans.push(Span::styled(
pad(shown_slug(view, repo).unwrap_or(""), slug_col_width(view)),
slug_style(view.colors),
));
}
let prefix = content_prefix(view, name_width);
if view.columns.is_statistics() {
spans.extend(stat_spans(view, repo));
} else {
match view.tab.kind() {
TabKind::Files => {
spans.extend(files_spans(repo, view, prefix, width))
}
TabKind::Git => spans.extend(git_spans(repo, view, prefix, width)),
}
}
let item = ListItem::new(Line::from(spans));
if selected {
item.style(Style::default().bg(view.colors.multi_select_bg))
} else {
item
}
}
fn files_spans(
repo: &Repo,
view: &SectionedView,
prefix: usize,
width: usize,
) -> Vec<Span<'static>> {
let kind = pad(type_label(repo), TYPE_WIDTH);
let path = pad(
&repo.path.to_string_lossy(),
files_path_width(prefix, width),
);
let zip = format!("{:>ZIP_WIDTH$}", zip_cell_text(repo, view));
vec![
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)),
]
}
fn git_spans(
repo: &Repo,
view: &SectionedView,
prefix: usize,
width: usize,
) -> Vec<Span<'static>> {
let (branch_w, status_w, github_w) = git_column_widths(view, prefix, width);
let info = effective_info(repo, view.example_mode);
let branch = pad(&branch_text(info), branch_w);
let github = pad(&github_text(info), github_w);
let zip = format!("{:>ZIP_WIDTH$}", zip_cell_text(repo, view));
let fg = Style::default().fg(view.colors.foreground);
let mut spans =
vec![Span::raw(" "), Span::styled(branch, fg), Span::raw(" ")];
spans.extend(git_status_spans(repo, view, info, status_w));
spans.extend([
Span::raw(" "),
Span::styled(github, fg),
Span::raw(" "),
Span::styled(zip, Style::default().fg(view.colors.dim)),
]);
spans
}
fn git_status_spans(
repo: &Repo,
view: &SectionedView,
info: Option<&crate::domain::repo::GitInfo>,
width: usize,
) -> Vec<Span<'static>> {
if view.is_in_flight(repo) {
return vec![Span::styled(
pad(view.spinner_glyph(), width),
Style::default().fg(view.colors.accent),
)];
}
let text = status_display(info, view.icons);
let used = UnicodeWidthStr::width(text.as_str()).min(width);
vec![
status_span(info, view.icons, view.colors),
Span::raw(" ".repeat(width.saturating_sub(used))),
]
}
fn git_column_widths(
view: &SectionedView,
prefix: usize,
width: usize,
) -> (usize, usize, usize) {
let branch = col_content(view, |r| {
branch_text(effective_info(r, view.example_mode))
})
.clamp(BRANCH_MIN, BRANCH_MAX);
let status = col_content(view, |r| {
status_display(effective_info(r, view.example_mode), view.icons)
})
.clamp(STATUS_MIN, STATUS_MAX);
let used =
2 + 1 + 1 + 1 + prefix + 2 + branch + 2 + status + 2 + 2 + ZIP_WIDTH;
let github = width.saturating_sub(used);
(branch, status, github)
}
fn col_content<F>(view: &SectionedView, cell: F) -> usize
where
F: Fn(&Repo) -> String,
{
view.groups
.iter()
.flat_map(|group| group.items.iter())
.map(|&index| UnicodeWidthStr::width(cell(&view.repos[index]).as_str()))
.max()
.unwrap_or(0)
}
fn marker_span(repo: &Repo, view: &SectionedView) -> Span<'static> {
let errored = git_marker_errored(repo, view.example_mode)
|| view.missing.contains(&repo.path);
if errored {
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| {
UnicodeWidthStr::width(view.repos[index].display_name().as_str())
})
.max()
.unwrap_or(NAME_MIN)
.clamp(NAME_MIN, NAME_MAX)
}
fn slug_col_width(view: &SectionedView) -> usize {
if !view.show_slugs {
return 0;
}
view.groups
.iter()
.flat_map(|group| group.items.iter())
.map(|&index| {
view.repos[index]
.slug
.as_deref()
.map_or(0, UnicodeWidthStr::width)
})
.max()
.unwrap_or(0)
.max(SLUG_HEADER.len())
.min(SLUG_MAX)
}
fn content_prefix(view: &SectionedView, name_width: usize) -> usize {
let slug = if view.show_slugs {
2 + slug_col_width(view)
} else {
0
};
name_width + slug
}
fn files_path_width(prefix: usize, width: usize) -> usize {
let used = 2 + 1 + 1 + 1 + prefix + 2 + TYPE_WIDTH + 2 + 2 + ZIP_WIDTH;
width.saturating_sub(used)
}
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, width: usize) -> Vec<Span<'static>> {
vec![Span::raw(pad(name, width))]
}
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 {
zip_date_text(repo, view.icons, view.zip_backups)
}
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_pads_the_name() {
let spans = name_field_spans("hop", 10);
assert_eq!(spans.len(), 1);
}
}