use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use crate::domain::filter::{Tab, TabKind};
use crate::domain::sort::SortMode;
use crate::domain::stats::{
CodeEntry, GitStats, Totals, format_age, format_bytes, format_count,
top_language,
};
use crate::tui::skin::Colors;
const BAR_LABEL: &str = "Columns";
const BAR_SEPARATOR: &str = " \u{203a} ";
pub const BAR_ROWS: u16 = 3;
pub const TOTALS_ROWS: u16 = 2;
const MIN_CONTENT_ROWS: u16 = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColumnSet {
#[default]
Standard,
Code,
Activity,
}
impl ColumnSet {
pub fn all(tab: Tab) -> &'static [ColumnSet] {
match tab.kind() {
TabKind::Files => &[ColumnSet::Standard, ColumnSet::Code],
TabKind::Git => {
&[ColumnSet::Standard, ColumnSet::Code, ColumnSet::Activity]
}
}
}
pub fn next(self, tab: Tab) -> Self {
let sets = ColumnSet::all(tab);
let index = sets.iter().position(|set| *set == self).unwrap_or(0);
sets[(index + 1) % sets.len()]
}
pub fn available_on(self, tab: Tab) -> Self {
if ColumnSet::all(tab).contains(&self) {
self
} else {
ColumnSet::Standard
}
}
pub fn title(self) -> &'static str {
match self {
ColumnSet::Standard => "Standard",
ColumnSet::Code => "Code",
ColumnSet::Activity => "Activity",
}
}
pub fn as_key(self) -> &'static str {
match self {
ColumnSet::Standard => "standard",
ColumnSet::Code => "code",
ColumnSet::Activity => "activity",
}
}
pub fn from_key(value: &str) -> Self {
match value.trim().to_lowercase().as_str() {
"code" => ColumnSet::Code,
"activity" => ColumnSet::Activity,
_ => ColumnSet::Standard,
}
}
pub fn is_statistics(self) -> bool {
self != ColumnSet::Standard
}
pub fn needs_code_stats(self) -> bool {
self == ColumnSet::Code
}
pub fn needs_git_stats(self) -> bool {
self == ColumnSet::Activity
}
pub fn sort_modes(self) -> &'static [SortMode] {
match self {
ColumnSet::Standard => &[],
ColumnSet::Code => {
&[SortMode::Loc, SortMode::Size, SortMode::Files]
}
ColumnSet::Activity => &[
SortMode::Commits,
SortMode::LastCommit,
SortMode::Authors,
SortMode::Branches,
SortMode::Opens,
SortMode::LastUsed,
],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatColumn {
Loc,
Language,
Files,
Size,
Commits,
LastCommit,
Authors,
Branches,
Opens,
LastUsed,
}
impl StatColumn {
pub fn title(self) -> &'static str {
match self {
StatColumn::Loc => "LOC",
StatColumn::Language => "Language",
StatColumn::Files => "Files",
StatColumn::Size => "Size",
StatColumn::Commits => "Commits",
StatColumn::LastCommit => "Last",
StatColumn::Authors => "Authors",
StatColumn::Branches => "Branches",
StatColumn::Opens => "Opens",
StatColumn::LastUsed => "Last used",
}
}
pub fn width(self) -> u16 {
match self {
StatColumn::Loc => 9,
StatColumn::Language => 10,
StatColumn::Files => 6,
StatColumn::Size => 9,
StatColumn::Commits => 8,
StatColumn::LastCommit => 6,
StatColumn::Authors => 8,
StatColumn::Branches => 8,
StatColumn::Opens => 6,
StatColumn::LastUsed => 10,
}
}
pub fn is_numeric(self) -> bool {
self != StatColumn::Language
}
}
pub fn stat_columns(set: ColumnSet) -> &'static [StatColumn] {
match set {
ColumnSet::Standard => &[],
ColumnSet::Code => &[
StatColumn::Loc,
StatColumn::Language,
StatColumn::Files,
StatColumn::Size,
],
ColumnSet::Activity => &[
StatColumn::Commits,
StatColumn::LastCommit,
StatColumn::Authors,
StatColumn::Branches,
StatColumn::Opens,
StatColumn::LastUsed,
],
}
}
#[derive(Debug, Clone, Copy)]
pub struct CellSource<'a> {
pub code: Option<&'a CodeEntry>,
pub git: Option<&'a GitStats>,
pub open_count: u64,
pub last_used: Option<i64>,
pub pending: bool,
pub now: i64,
}
const NO_VALUE: &str = "-";
pub fn cell_text(column: StatColumn, source: CellSource) -> Option<String> {
let text = match column {
StatColumn::Loc => {
return numeric(source, |c, _| {
format_count(c.code.total().code as u64)
});
}
StatColumn::Files => {
return numeric(source, |c, _| {
format_count(c.code.total().files as u64)
});
}
StatColumn::Size => {
return numeric(source, |c, _| format_bytes(c.disk.total_bytes));
}
StatColumn::Language => {
return numeric(source, |c, _| {
top_language(&c.code).unwrap_or(NO_VALUE).to_string()
});
}
StatColumn::Commits => {
return history(source, |g, _| format_count(g.commits));
}
StatColumn::Authors => {
return history(source, |g, _| format_count(g.contributors));
}
StatColumn::Branches => {
return history(source, |g, _| format_count(g.branches));
}
StatColumn::LastCommit => {
return history(source, |g, now| match g.last_commit {
Some(at) => format_age(at, now),
None => NO_VALUE.to_string(),
});
}
StatColumn::Opens => format_count(source.open_count),
StatColumn::LastUsed => match source.last_used {
Some(at) => format_age(at, source.now),
None => NO_VALUE.to_string(),
},
};
Some(text)
}
fn numeric(
source: CellSource,
render: impl Fn(&CodeEntry, i64) -> String,
) -> Option<String> {
match source.code {
Some(code) => Some(render(code, source.now)),
None if source.pending => None,
None => Some(NO_VALUE.to_string()),
}
}
fn history(
source: CellSource,
render: impl Fn(&GitStats, i64) -> String,
) -> Option<String> {
match source.git {
Some(git) => Some(render(git, source.now)),
None if source.pending => None,
None => Some(NO_VALUE.to_string()),
}
}
pub fn footer_rows(set: ColumnSet, content_height: u16) -> u16 {
if !set.is_statistics() || content_height < MIN_CONTENT_ROWS {
return 0;
}
BAR_ROWS + TOTALS_ROWS
}
pub fn render_footer(
frame: &mut ratatui::Frame,
area: Rect,
(set, tab): (ColumnSet, Tab),
(totals, colors): (Totals, &Colors),
) {
if area.height < BAR_ROWS + TOTALS_ROWS {
return;
}
let rule = Rect {
height: TOTALS_ROWS,
..area
};
frame
.render_widget(Paragraph::new(totals_lines(set, totals, colors)), rule);
let bar = Rect {
x: area.x + 1,
y: area.y + TOTALS_ROWS + 1,
width: area.width.saturating_sub(2),
height: 1,
};
frame.render_widget(
Block::default().style(Style::default().bg(colors.surface_bg)),
bar,
);
frame.render_widget(Paragraph::new(bar_line(set, tab, colors)), bar);
}
fn totals_lines(
set: ColumnSet,
totals: Totals,
colors: &Colors,
) -> Vec<Line<'static>> {
let dim = Style::default().fg(colors.dim);
let value = Style::default().fg(colors.foreground);
let mut spans = vec![
Span::styled(
format!(" {} projects", totals.entries),
Style::default().fg(colors.muted),
),
Span::styled(" ", dim),
];
if set == ColumnSet::Code {
spans.extend([
Span::styled(format_count(totals.code as u64), value),
Span::styled(" lines ", dim),
Span::styled(format_count(totals.files as u64), value),
Span::styled(" files ", dim),
Span::styled(format_bytes(totals.bytes), value),
]);
}
vec![Line::from(Span::styled("", dim)), Line::from(spans)]
}
fn bar_line(set: ColumnSet, tab: Tab, colors: &Colors) -> Line<'static> {
let mut spans = vec![Span::styled(
format!(" {BAR_LABEL} "),
Style::default().fg(colors.muted),
)];
for (index, candidate) in ColumnSet::all(tab).iter().enumerate() {
if index > 0 {
spans.push(Span::styled(
BAR_SEPARATOR,
Style::default().fg(colors.dim),
));
}
let style = if *candidate == set {
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.dim)
};
spans.push(Span::styled(candidate.title().to_string(), style));
}
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_files_tabs_skip_the_activity_set() {
assert_eq!(ColumnSet::all(Tab::FilesActive).len(), 2);
assert_eq!(ColumnSet::all(Tab::FilesArchive).len(), 2);
assert_eq!(ColumnSet::all(Tab::GitActive).len(), 3);
assert_eq!(ColumnSet::all(Tab::GitArchive).len(), 3);
let set = ColumnSet::Standard.next(Tab::FilesActive);
assert_eq!(set, ColumnSet::Code);
assert_eq!(set.next(Tab::FilesActive), ColumnSet::Standard);
}
#[test]
fn cycling_the_git_tab_visits_every_set() {
let tab = Tab::GitActive;
let mut set = ColumnSet::Standard;
set = set.next(tab);
assert_eq!(set, ColumnSet::Code);
set = set.next(tab);
assert_eq!(set, ColumnSet::Activity);
assert_eq!(set.next(tab), ColumnSet::Standard);
}
#[test]
fn a_set_the_tab_does_not_offer_falls_back_to_standard() {
let set = ColumnSet::Activity.available_on(Tab::FilesActive);
assert_eq!(set, ColumnSet::Standard);
assert_eq!(
ColumnSet::Code.available_on(Tab::FilesActive),
ColumnSet::Code
);
}
#[test]
fn keys_round_trip_and_unknown_falls_back() {
for set in [ColumnSet::Standard, ColumnSet::Code, ColumnSet::Activity] {
assert_eq!(ColumnSet::from_key(set.as_key()), set);
}
assert_eq!(ColumnSet::from_key("bogus"), ColumnSet::Standard);
}
#[test]
fn each_set_declares_exactly_the_worker_it_needs() {
assert!(!ColumnSet::Activity.needs_code_stats());
assert!(ColumnSet::Activity.needs_git_stats());
assert!(ColumnSet::Code.needs_code_stats());
assert!(!ColumnSet::Code.needs_git_stats());
assert!(!ColumnSet::Standard.needs_code_stats());
assert!(!ColumnSet::Standard.needs_git_stats());
}
#[test]
fn the_footer_disappears_in_standard_and_on_a_short_terminal() {
assert_eq!(footer_rows(ColumnSet::Standard, 40), 0);
assert_eq!(footer_rows(ColumnSet::Code, 40), BAR_ROWS + TOTALS_ROWS);
assert_eq!(footer_rows(ColumnSet::Code, MIN_CONTENT_ROWS - 1), 0);
}
fn source(pending: bool) -> CellSource<'static> {
CellSource {
code: None,
git: None,
open_count: 42,
last_used: None,
pending,
now: 1_000_000,
}
}
#[test]
fn a_cell_awaiting_its_worker_has_no_text_so_a_spinner_can_show() {
assert_eq!(cell_text(StatColumn::Loc, source(true)), None);
assert_eq!(
cell_text(StatColumn::Loc, source(false)),
Some("-".to_string())
);
assert_eq!(
cell_text(StatColumn::Opens, source(true)),
Some("42".to_string())
);
}
#[test]
fn only_the_statistics_sets_offer_column_sort_modes() {
assert!(ColumnSet::Standard.sort_modes().is_empty());
assert!(ColumnSet::Code.sort_modes().contains(&SortMode::Loc));
assert!(
ColumnSet::Activity
.sort_modes()
.contains(&SortMode::Commits)
);
}
}