use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
pub(super) use tui_pane::ColumnSpec;
pub(super) use tui_pane::ColumnWidths;
use tui_pane::error_color;
use tui_pane::label_color;
use tui_pane::secondary_text_color;
use tui_pane::text_default;
use tui_pane::title_color;
use unicode_width::UnicodeWidthStr;
use super::render;
use super::theme_roles;
use crate::ci::CiStatus;
use crate::constants::IN_SYNC;
use crate::project::GitStatus;
use crate::project::WorktreeHealth;
use crate::project::WorktreeHealth::Normal;
pub(super) const COL_NAME: usize = 0;
pub(super) const COL_LINT: usize = 1;
pub(super) const COL_CI: usize = 2;
pub(super) const COL_LANG: usize = 3;
pub(super) const COL_GIT_PATH: usize = 4;
pub(super) const COL_SYNC: usize = 5;
pub(super) const COL_MAIN: usize = 6;
pub(super) const COL_DISK: usize = 7;
pub(super) const NUM_COLS: usize = 8;
#[derive(Clone, Copy)]
pub(super) enum ColumnWidth {
Fixed(usize),
Fit { min: usize },
}
#[derive(Clone, Copy)]
pub(super) enum Align {
Left,
Right,
Center,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum HeaderMode {
Standard,
BorrowLeft,
Hidden,
}
#[derive(Clone, Copy)]
pub(super) struct ColumnDef {
pub header_levels: &'static [&'static str],
pub width: ColumnWidth,
pub align: Align,
pub gap: usize,
pub header_mode: HeaderMode,
}
impl ColumnDef {
pub(super) fn header_min(&self) -> &'static str {
self.header_levels.first().copied().unwrap_or("")
}
pub(super) fn header_max(&self) -> &'static str {
self.header_levels.last().copied().unwrap_or("")
}
pub(super) fn header_for_width(&self, width: usize) -> &'static str {
self.header_levels
.iter()
.rev()
.copied()
.find(|label| display_width(label) <= width)
.unwrap_or_else(|| self.header_min())
}
pub(super) fn seed_width(&self) -> usize {
let base = match self.width {
ColumnWidth::Fixed(width) | ColumnWidth::Fit { min: width } => width,
};
if matches!(self.width, ColumnWidth::Fit { .. }) && self.header_mode == HeaderMode::Standard
{
base.max(display_width(self.header_min()))
} else {
base
}
}
}
pub(super) const fn column_defs(lint_enabled: bool) -> [ColumnDef; NUM_COLS] {
[
ColumnDef {
header_levels: &[""],
width: ColumnWidth::Fit { min: 10 },
align: Align::Left,
gap: 0,
header_mode: HeaderMode::Standard,
},
ColumnDef {
header_levels: if lint_enabled { &["Lint"] } else { &[""] },
width: ColumnWidth::Fixed(if lint_enabled { 2 } else { 0 }),
align: Align::Left,
gap: 0,
header_mode: if lint_enabled {
HeaderMode::BorrowLeft
} else {
HeaderMode::Hidden
},
},
ColumnDef {
header_levels: &["CI"],
width: ColumnWidth::Fixed(2),
align: Align::Left,
gap: 1,
header_mode: HeaderMode::Standard,
},
ColumnDef {
header_levels: &[""],
width: ColumnWidth::Fixed(2),
align: Align::Left,
gap: 1,
header_mode: HeaderMode::Hidden,
},
ColumnDef {
header_levels: &["Git"],
width: ColumnWidth::Fixed(2),
align: Align::Right,
gap: 1,
header_mode: HeaderMode::BorrowLeft,
},
ColumnDef {
header_levels: &["Og", "Orig", "Origin"],
width: ColumnWidth::Fit { min: 0 },
align: Align::Right,
gap: 1,
header_mode: HeaderMode::Standard,
},
ColumnDef {
header_levels: &["M", "Mn", "Main"],
width: ColumnWidth::Fit { min: 0 },
align: Align::Right,
gap: 1,
header_mode: HeaderMode::Standard,
},
ColumnDef {
header_levels: &["Disk"],
width: ColumnWidth::Fit { min: 4 },
align: Align::Right,
gap: 1,
header_mode: HeaderMode::Standard,
},
]
}
#[derive(Default)]
pub(super) struct CellContent {
pub text: String,
pub style: Style,
pub segments: Option<Vec<StyledSegment>>,
pub align_override: Option<Align>,
pub suffix: Option<String>,
pub suffix_style: Option<Style>,
}
#[derive(Clone)]
pub(super) struct StyledSegment {
pub text: String,
pub style: Style,
}
#[derive(Clone, Copy)]
pub(super) struct LintCell {
icon: &'static str,
style: Style,
}
impl LintCell {
pub(super) const fn hidden() -> Self {
Self {
icon: " ",
style: Style::new(),
}
}
#[cfg(test)]
pub(super) const fn with_icon(icon: &'static str) -> Self {
Self {
icon,
style: Style::new(),
}
}
pub(super) const fn from_parts(icon: &'static str, style: Style) -> Self {
Self { icon, style }
}
pub(super) const fn icon(&self) -> &'static str { self.icon }
pub(super) const fn style(&self) -> Style { self.style }
}
#[derive(Clone)]
pub(super) struct ProjectRow<'a> {
pub prefix: &'a str,
pub name: &'a str,
pub name_segments: Option<Vec<StyledSegment>>,
pub git_status: Option<GitStatus>,
pub lint: LintCell,
pub disk: &'a str,
pub disk_style: Style,
pub disk_suffix: Option<&'a str>,
pub disk_suffix_style: Option<Style>,
pub lang_icon: &'a str,
pub git_origin_sync: &'a str,
pub git_main: &'a str,
pub ci: Option<CiStatus>,
pub deleted: bool,
pub worktree_health: WorktreeHealth,
}
pub(super) struct RowCells {
pub cells: [CellContent; NUM_COLS],
pub prefix: String,
pub deleted: bool,
pub worktree_health: WorktreeHealth,
}
pub(super) struct ProjectListWidths {
inner: ColumnWidths,
lint_enabled: bool,
pub generation: u64,
}
impl Default for ProjectListWidths {
fn default() -> Self { Self::new(true) }
}
impl ProjectListWidths {
pub(super) fn new(lint_enabled: bool) -> Self {
Self {
inner: ColumnWidths::new(project_list_specs(lint_enabled)),
lint_enabled,
generation: u64::MAX,
}
}
pub(super) fn observe(&mut self, col: usize, width: usize) {
self.inner.observe_cell_usize(col, width);
}
pub(super) fn get(&self, col: usize) -> usize { usize::from(self.inner.get(col)) }
pub(super) fn total_width(&self) -> usize {
let defs = column_defs(self.lint_enabled);
let mut total = 0;
for (i, def) in defs.iter().enumerate() {
total += def.gap + self.get(i);
}
total
}
pub const fn lint_enabled(&self) -> bool { self.lint_enabled }
}
fn project_list_specs(lint_enabled: bool) -> Vec<ColumnSpec> {
column_defs(lint_enabled)
.iter()
.map(|def| {
let seed = u16::try_from(def.seed_width()).unwrap_or(u16::MAX);
match def.width {
ColumnWidth::Fixed(_) => ColumnSpec::fixed(seed),
ColumnWidth::Fit { .. } => ColumnSpec::fit(seed),
}
})
.collect()
}
pub(super) fn display_width(s: &str) -> usize { UnicodeWidthStr::width(s) }
pub(super) fn pad_right(s: &str, target: usize) -> String {
let w = display_width(s);
let pad = target.saturating_sub(w);
format!("{s}{}", " ".repeat(pad))
}
pub(super) fn pad_left(s: &str, target: usize) -> String {
let w = display_width(s);
let pad = target.saturating_sub(w);
format!("{}{s}", " ".repeat(pad))
}
fn pad_center(s: &str, target: usize) -> String {
let w = display_width(s);
let total_pad = target.saturating_sub(w);
let left = total_pad / 2;
let right = total_pad - left;
format!("{}{s}{}", " ".repeat(left), " ".repeat(right))
}
pub(super) fn row_to_line(row: &RowCells, widths: &ProjectListWidths) -> Line<'static> {
let defs = column_defs(widths.lint_enabled());
let mut spans = Vec::with_capacity(NUM_COLS);
let mut suffix_indices: Vec<usize> = Vec::new();
for (i, cell) in row.cells.iter().enumerate() {
let col_width = widths.get(i);
let align = cell.align_override.unwrap_or(defs[i].align);
if col_width == 0 {
spans.push(Span::styled(String::new(), cell.style));
continue;
}
if let Some(suffix) = &cell.suffix {
let suffix_w = display_width(suffix);
let text_w = col_width.saturating_sub(suffix_w);
let text_padded = pad_left(&cell.text, text_w);
let gap = " ".repeat(defs[i].gap);
spans.push(Span::styled(format!("{gap}{text_padded}"), cell.style));
let suffix_style = cell.suffix_style.unwrap_or(cell.style);
suffix_indices.push(spans.len());
spans.push(Span::styled(suffix.clone(), suffix_style));
continue;
}
if i == COL_NAME
&& let Some(segments) = &cell.segments
{
let prefix_w = display_width(&row.prefix);
let available = col_width.saturating_sub(prefix_w);
let content_w = segments
.iter()
.map(|segment| display_width(&segment.text))
.sum();
spans.push(Span::styled(row.prefix.clone(), cell.style));
for segment in segments {
spans.push(Span::styled(segment.text.clone(), segment.style));
}
let padding = available.saturating_sub(content_w);
if padding > 0 {
spans.push(Span::styled(" ".repeat(padding), cell.style));
}
continue;
}
let content = if i == COL_NAME {
let prefix_w = display_width(&row.prefix);
let available = col_width.saturating_sub(prefix_w);
format!("{}{}", row.prefix, pad_right(&cell.text, available))
} else if (i == COL_SYNC || i == COL_MAIN) && cell.text == IN_SYNC {
let padded = pad_left(&cell.text, col_width);
format!("{}{padded}", " ".repeat(defs[i].gap))
} else {
let padded = match align {
Align::Left => pad_right(&cell.text, col_width),
Align::Right => pad_left(&cell.text, col_width),
Align::Center => pad_center(&cell.text, col_width),
};
format!("{}{padded}", " ".repeat(defs[i].gap))
};
spans.push(Span::styled(content, cell.style));
}
if row.deleted {
let strike = Style::default()
.fg(label_color())
.add_modifier(Modifier::CROSSED_OUT);
for (i, span) in spans.iter_mut().enumerate() {
if !suffix_indices.contains(&i) {
span.style = strike;
}
}
} else if matches!(row.worktree_health, WorktreeHealth::Broken) {
let broken_style = Style::default().fg(text_default()).bg(error_color());
for span in &mut spans {
span.style = broken_style;
}
}
Line::from(spans)
}
pub(super) fn header_line(widths: &ProjectListWidths, name_text: &str) -> Line<'static> {
let defs = column_defs(widths.lint_enabled());
let header_style = Style::default()
.fg(theme_roles::column_header_color())
.add_modifier(Modifier::BOLD);
let mut spans = Vec::with_capacity(NUM_COLS);
let mut slot_widths =
std::array::from_fn::<usize, NUM_COLS, _>(|i| defs[i].gap + widths.get(i));
for (i, def) in defs.iter().enumerate() {
if def.header_mode != HeaderMode::BorrowLeft {
continue;
}
let mut borrow_needed = display_width(def.header_max()).saturating_sub(widths.get(i));
let mut donor = i;
while borrow_needed > 0 && donor > 0 {
donor -= 1;
let borrowed = slot_widths[donor].min(borrow_needed);
slot_widths[donor] -= borrowed;
slot_widths[i] += borrowed;
borrow_needed -= borrowed;
}
}
for (i, def) in defs.iter().enumerate() {
let slot_width = slot_widths[i];
let content = if i == COL_NAME {
pad_right(name_text, slot_width)
} else if def.header_mode == HeaderMode::BorrowLeft {
let header = def.header_for_width(slot_width);
match def.align {
Align::Left => pad_right(header, slot_width),
Align::Right => pad_left(header, slot_width),
Align::Center => pad_center(header, slot_width),
}
} else if def.header_mode == HeaderMode::Hidden {
" ".repeat(slot_width)
} else {
let gap = def.gap.min(slot_width);
let content_width = slot_width.saturating_sub(gap);
let header = def.header_for_width(content_width);
let padded = match def.align {
Align::Left => pad_right(header, content_width),
Align::Right => pad_left(header, content_width),
Align::Center => pad_center(header, content_width),
};
format!("{}{padded}", " ".repeat(gap))
};
spans.push(Span::styled(content, header_style));
}
Line::from(spans)
}
pub(super) fn build_row_cells(row: ProjectRow<'_>) -> RowCells {
let ci_text = row
.ci
.map_or(String::new(), |conclusion| String::from(conclusion.icon()));
let git_path_icon = row.git_status.map_or("", GitStatus::icon);
let compact_status_style = |value: &str| {
if value == IN_SYNC {
Style::default().fg(theme_roles::git_untracked_color())
} else {
Style::default().fg(text_default())
}
};
let compact_status_align = |value: &str| {
if value == IN_SYNC {
Some(Align::Center)
} else {
None
}
};
let origin_sync_style = compact_status_style(row.git_origin_sync);
let main_style = compact_status_style(row.git_main);
let origin_sync_align = compact_status_align(row.git_origin_sync);
let main_align = compact_status_align(row.git_main);
let name_style = project_name_style(row.git_status);
let ci_style = render::conclusion_style(row.ci);
let git_path_style = Style::default();
let mut cells = std::array::from_fn::<CellContent, NUM_COLS, _>(|_| CellContent::default());
cells[COL_NAME] = CellContent {
text: String::from(row.name),
style: name_style,
segments: row.name_segments,
align_override: None,
..CellContent::default()
};
cells[COL_LINT] = CellContent {
text: String::from(row.lint.icon()),
style: row.lint.style(),
align_override: None,
..CellContent::default()
};
cells[COL_CI] = CellContent {
text: ci_text,
style: ci_style,
align_override: None,
..CellContent::default()
};
cells[COL_LANG] = CellContent {
text: String::from(row.lang_icon),
style: Style::default(),
align_override: None,
..CellContent::default()
};
cells[COL_GIT_PATH] = CellContent {
text: String::from(git_path_icon),
style: git_path_style,
align_override: Some(Align::Center),
..CellContent::default()
};
cells[COL_SYNC] = CellContent {
text: String::from(row.git_origin_sync),
style: origin_sync_style,
align_override: origin_sync_align,
..CellContent::default()
};
cells[COL_MAIN] = CellContent {
text: String::from(row.git_main),
style: main_style,
align_override: main_align,
..CellContent::default()
};
cells[COL_DISK] = CellContent {
text: String::from(row.disk),
style: row.disk_style,
align_override: None,
suffix: row.disk_suffix.map(String::from),
suffix_style: row.disk_suffix_style,
..CellContent::default()
};
RowCells {
cells,
prefix: String::from(row.prefix),
deleted: row.deleted,
worktree_health: row.worktree_health,
}
}
pub(super) fn project_name_style(git_status: Option<GitStatus>) -> Style {
match git_status {
Some(GitStatus::Modified) => Style::default().fg(theme_roles::git_modified_color()),
Some(GitStatus::Untracked) => Style::default().fg(theme_roles::git_untracked_color()),
Some(GitStatus::Ignored) => Style::default().fg(theme_roles::git_ignored_color()),
Some(GitStatus::Clean) | None => Style::default(),
}
}
pub(super) fn project_name_shimmer_style(git_status: Option<GitStatus>) -> Style {
match git_status {
Some(GitStatus::Modified) => Style::default().fg(theme_roles::git_modified_color()),
Some(GitStatus::Untracked) => Style::default().fg(theme_roles::git_untracked_color()),
Some(GitStatus::Ignored) => Style::default().fg(secondary_text_color()),
Some(GitStatus::Clean) | None => {
Style::default().fg(theme_roles::discovery_shimmer_color())
},
}
}
pub(super) fn build_shimmer_segments(
name: &str,
base_style: Style,
accent_style: Style,
head: usize,
window_len: usize,
) -> Vec<StyledSegment> {
let chars: Vec<char> = name.chars().collect();
if chars.is_empty() || window_len == 0 {
return vec![StyledSegment {
text: name.to_string(),
style: base_style,
}];
}
let len = chars.len();
let head = head % len;
let window_len = window_len.min(len);
let mut segments = Vec::new();
let mut current = String::new();
let mut highlighted = false;
for (index, ch) in chars.iter().enumerate() {
let is_highlighted = (index + len - head) % len < window_len;
if current.is_empty() {
highlighted = is_highlighted;
} else if is_highlighted != highlighted {
segments.push(StyledSegment {
text: std::mem::take(&mut current),
style: if highlighted {
accent_style
} else {
base_style
},
});
highlighted = is_highlighted;
}
current.push(*ch);
}
if !current.is_empty() {
segments.push(StyledSegment {
text: current,
style: if highlighted {
accent_style
} else {
base_style
},
});
}
segments
}
pub(super) fn build_group_header_cells(prefix: &str, label: &str) -> RowCells {
let mut cells = std::array::from_fn::<CellContent, NUM_COLS, _>(|_| CellContent::default());
cells[COL_NAME] = CellContent {
text: String::from(label),
style: Style::default().fg(title_color()),
align_override: None,
..CellContent::default()
};
RowCells {
cells,
prefix: String::from(prefix),
deleted: false,
worktree_health: Normal,
}
}
fn summary_label_col(widths: &ProjectListWidths) -> usize {
(0..COL_DISK)
.rev()
.find(|&col| widths.get(col) > 0)
.unwrap_or(COL_NAME)
}
pub(super) fn build_summary_cells(widths: &ProjectListWidths, disk: &str) -> RowCells {
let total_style = Style::default()
.fg(title_color())
.add_modifier(Modifier::BOLD);
let mut cells = std::array::from_fn::<CellContent, NUM_COLS, _>(|_| CellContent::default());
let sigma_col = summary_label_col(widths);
cells[sigma_col] = CellContent {
text: String::from("Σ"),
style: total_style,
align_override: Some(Align::Right),
..CellContent::default()
};
cells[COL_DISK] = CellContent {
text: String::from(disk),
style: total_style,
align_override: None,
..CellContent::default()
};
if sigma_col != COL_LANG {
cells[COL_LANG] = CellContent {
text: String::from(" "),
style: Style::default(),
align_override: None,
..CellContent::default()
};
}
RowCells {
cells,
prefix: " ".repeat(widths.get(COL_NAME)),
deleted: false,
worktree_health: Normal,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::WorktreeHealth;
fn seeded_width(index: usize) -> usize { column_defs(true)[index].seed_width() }
#[test]
fn resolved_widths_seeds_from_defs() {
let widths = ProjectListWidths::new(true);
assert_eq!(widths.get(COL_LINT), seeded_width(COL_LINT));
assert_eq!(widths.get(COL_LANG), seeded_width(COL_LANG));
assert_eq!(widths.get(COL_CI), seeded_width(COL_CI));
assert_eq!(widths.get(COL_GIT_PATH), seeded_width(COL_GIT_PATH));
assert_eq!(widths.get(COL_NAME), seeded_width(COL_NAME));
assert_eq!(widths.get(COL_DISK), seeded_width(COL_DISK));
assert_eq!(widths.get(COL_SYNC), seeded_width(COL_SYNC));
assert_eq!(widths.get(COL_MAIN), seeded_width(COL_MAIN));
}
#[test]
fn observe_grows_fit_columns() {
let mut widths = ProjectListWidths::new(true);
widths.observe(COL_NAME, 25);
assert_eq!(widths.get(COL_NAME), 25);
widths.observe(COL_LINT, 99);
assert_eq!(widths.get(COL_LINT), seeded_width(COL_LINT));
}
#[test]
fn total_width_sums_gaps_and_widths() {
let defs = column_defs(true);
let widths = ProjectListWidths::new(true);
let total = widths.total_width();
let expected: usize = defs
.iter()
.enumerate()
.map(|(i, d)| d.gap + widths.get(i))
.sum();
assert_eq!(total, expected);
}
#[test]
fn header_line_borrows_only_overflow_from_name() {
let mut widths = ProjectListWidths::new(true);
widths.observe(COL_NAME, 30);
widths.observe(COL_DISK, 8);
widths.observe(COL_SYNC, 2);
widths.observe(COL_MAIN, 2);
let line = header_line(&widths, "Projects");
assert_eq!(display_width(line.spans[COL_NAME].content.as_ref()), 28);
assert_eq!(display_width(line.spans[COL_LINT].content.as_ref()), 4);
assert_eq!(line.spans[COL_CI].content.as_ref(), " CI");
assert_eq!(line.spans[COL_GIT_PATH].content.as_ref(), " Git");
assert_eq!(line.spans[COL_SYNC].content.as_ref(), " Og");
assert_eq!(line.spans[COL_MAIN].content.as_ref(), " Mn");
assert_eq!(line.spans[COL_DISK].content.as_ref(), " Disk");
assert_eq!(line.width(), widths.total_width());
}
#[test]
fn git_header_borrows_from_hidden_lang_column() {
let mut widths = ProjectListWidths::new(true);
widths.observe(COL_NAME, 30);
widths.observe(COL_DISK, 8);
widths.observe(COL_SYNC, 2);
widths.observe(COL_MAIN, 2);
let line = header_line(&widths, "Projects");
assert_eq!(line.spans[COL_CI].content.as_ref(), " CI");
assert_eq!(display_width(line.spans[COL_LANG].content.as_ref()), 2);
assert_eq!(line.spans[COL_GIT_PATH].content.as_ref(), " Git");
assert_eq!(line.spans[COL_SYNC].content.as_ref(), " Og");
assert_eq!(line.spans[COL_MAIN].content.as_ref(), " Mn");
assert_eq!(line.width(), widths.total_width());
}
#[test]
fn header_levels_promote_with_observed_width() {
let widths = ProjectListWidths::new(true);
let defs = column_defs(true);
assert_eq!(defs[COL_MAIN].header_for_width(widths.get(COL_MAIN)), "M");
assert_eq!(defs[COL_SYNC].header_for_width(widths.get(COL_SYNC)), "Og");
let mut widths = ProjectListWidths::new(true);
widths.observe(COL_MAIN, 3);
widths.observe(COL_SYNC, 5);
assert_eq!(defs[COL_MAIN].header_for_width(widths.get(COL_MAIN)), "Mn");
assert_eq!(
defs[COL_SYNC].header_for_width(widths.get(COL_SYNC)),
"Orig"
);
let mut widths = ProjectListWidths::new(true);
widths.observe(COL_MAIN, 4);
widths.observe(COL_SYNC, 6);
assert_eq!(
defs[COL_MAIN].header_for_width(widths.get(COL_MAIN)),
"Main"
);
assert_eq!(
defs[COL_SYNC].header_for_width(widths.get(COL_SYNC)),
"Origin"
);
}
#[test]
fn header_line_uses_widest_label_that_fits() {
let mut widths = ProjectListWidths::new(true);
widths.observe(COL_NAME, 30);
widths.observe(COL_DISK, 8);
widths.observe(COL_SYNC, 6);
widths.observe(COL_MAIN, 4);
let line = header_line(&widths, "Projects");
assert_eq!(line.spans[COL_SYNC].content.as_ref(), " Origin");
assert_eq!(line.spans[COL_MAIN].content.as_ref(), " Main");
}
#[test]
fn emoji_display_widths() {
assert_eq!(display_width("🌲"), 2);
assert_eq!(display_width("🦀"), 2);
assert_eq!(display_width("bevy_brp"), 8);
assert_eq!(display_width("bevy_brp 🌲:2"), 13);
let padded = pad_right("bevy_brp 🌲:2", 27);
assert_eq!(display_width(&padded), 27, "padded display width");
let padded_ascii = pad_right("bevy_brp", 27);
assert_eq!(
display_width(&padded_ascii),
27,
"ascii padded display width"
);
}
#[test]
fn row_to_line_same_width_with_and_without_emoji() {
let mut widths = ProjectListWidths::new(true);
widths.observe(COL_NAME, 32);
widths.observe(COL_DISK, 8);
widths.observe(COL_SYNC, 2);
widths.observe(COL_MAIN, 2);
let row_emoji = build_row_cells(ProjectRow {
prefix: "▶",
name: "bevy_brp 🌲:2",
name_segments: None,
git_status: Some(GitStatus::Clean),
lint: LintCell::with_icon(crate::constants::LINT_PASSED),
disk: "36.3 GiB",
disk_style: Style::default(),
disk_suffix: None,
disk_suffix_style: None,
lang_icon: "🦀",
git_origin_sync: "↑2",
git_main: "",
ci: Some(CiStatus::Passed),
deleted: false,
worktree_health: WorktreeHealth::Normal,
});
let row_ascii = build_row_cells(ProjectRow {
prefix: "▶",
name: "bevy_mesh_outline_benchmark",
name_segments: None,
git_status: Some(GitStatus::Clean),
lint: LintCell::with_icon(crate::constants::LINT_PASSED),
disk: "36.3 GiB",
disk_style: Style::default(),
disk_suffix: None,
disk_suffix_style: None,
lang_icon: "🦀",
git_origin_sync: "↑2",
git_main: "",
ci: Some(CiStatus::Passed),
deleted: false,
worktree_health: WorktreeHealth::Normal,
});
let line_emoji = row_to_line(&row_emoji, &widths);
let line_ascii = row_to_line(&row_ascii, &widths);
let emoji_spans: Vec<usize> = line_emoji
.spans
.iter()
.map(|s| display_width(s.content.as_ref()))
.collect();
let ascii_spans: Vec<usize> = line_ascii
.spans
.iter()
.map(|s| display_width(s.content.as_ref()))
.collect();
assert_eq!(
emoji_spans, ascii_spans,
"per-span widths should match\nemoji: {emoji_spans:?}\nascii: {ascii_spans:?}"
);
}
#[test]
fn summary_row_places_sigma_next_to_disk_total() {
let mut widths = ProjectListWidths::new(true);
widths.observe(COL_NAME, 30);
widths.observe(COL_DISK, 8);
widths.observe(COL_SYNC, 2);
widths.observe(COL_MAIN, 2);
let row = build_summary_cells(&widths, "36.3 GiB");
let line = row_to_line(&row, &widths);
assert_eq!(
line.spans[COL_NAME].content.as_ref(),
" ".repeat(widths.get(COL_NAME))
);
assert_eq!(line.spans[COL_MAIN].content.as_ref(), " Σ");
assert_eq!(line.spans[COL_CI].content.as_ref(), " ");
assert_eq!(line.spans[COL_DISK].content.as_ref(), " 36.3 GiB");
}
#[test]
fn lint_column_collapses_when_disabled() {
let defs = column_defs(false);
let mut widths = ProjectListWidths::new(false);
widths.observe(COL_NAME, 30);
widths.observe(COL_DISK, 8);
widths.observe(COL_SYNC, 2);
widths.observe(COL_MAIN, 2);
let header = header_line(&widths, "Projects");
let row = build_summary_cells(&widths, "36.3 GiB");
let line = row_to_line(&row, &widths);
assert_eq!(defs[COL_LINT].header_max(), "");
assert_eq!(widths.get(COL_LINT), 0);
assert_eq!(display_width(header.spans[COL_LINT].content.as_ref()), 0);
assert_eq!(defs[COL_CI].header_max(), "CI");
assert_eq!(widths.get(COL_CI), 2);
assert!(header.spans[COL_CI].content.as_ref().ends_with("CI"));
assert_eq!(line.spans[COL_MAIN].content.as_ref(), " Σ");
}
#[test]
fn hidden_lint_column_does_not_shift_ci_cells() {
let mut widths = ProjectListWidths::new(false);
widths.observe(COL_NAME, 24);
widths.observe(COL_DISK, 8);
widths.observe(COL_SYNC, 2);
widths.observe(COL_MAIN, 2);
let row = build_row_cells(ProjectRow {
prefix: "▶",
name: "demo",
name_segments: None,
git_status: Some(GitStatus::Clean),
lint: LintCell::with_icon(crate::constants::LINT_PASSED),
disk: "36.3 GiB",
disk_style: Style::default(),
disk_suffix: None,
disk_suffix_style: None,
lang_icon: "🦀",
git_origin_sync: "↑2",
git_main: "",
ci: Some(CiStatus::Passed),
deleted: false,
worktree_health: WorktreeHealth::Normal,
});
let line = row_to_line(&row, &widths);
assert_eq!(display_width(line.spans[COL_LINT].content.as_ref()), 0);
assert_eq!(
line.spans[COL_CI].content.as_ref(),
&format!(" {}", CiStatus::Passed.icon())
);
assert_eq!(line.width(), widths.total_width());
}
#[test]
fn git_status_changes_name_style() {
let modified = build_row_cells(ProjectRow {
prefix: " ",
name: "demo",
name_segments: None,
git_status: Some(GitStatus::Modified),
lint: LintCell::hidden(),
disk: "—",
disk_style: Style::default(),
disk_suffix: None,
disk_suffix_style: None,
lang_icon: "🦀",
git_origin_sync: "",
git_main: "",
ci: None,
deleted: false,
worktree_health: WorktreeHealth::Normal,
});
assert_eq!(
modified.cells[COL_NAME].style.fg,
Some(theme_roles::git_modified_color())
);
assert_eq!(
modified.cells[COL_GIT_PATH].text,
crate::constants::GIT_STATUS_MODIFIED
);
let untracked = build_row_cells(ProjectRow {
prefix: " ",
name: "demo",
name_segments: None,
git_status: Some(GitStatus::Untracked),
lint: LintCell::hidden(),
disk: "—",
disk_style: Style::default(),
disk_suffix: None,
disk_suffix_style: None,
lang_icon: "🦀",
git_origin_sync: "",
git_main: "",
ci: None,
deleted: false,
worktree_health: WorktreeHealth::Normal,
});
assert_eq!(
untracked.cells[COL_NAME].style.fg,
Some(theme_roles::git_untracked_color())
);
assert_eq!(
untracked.cells[COL_GIT_PATH].text,
crate::constants::GIT_STATUS_UNTRACKED
);
let clean = build_row_cells(ProjectRow {
prefix: " ",
name: "demo",
name_segments: None,
git_status: Some(GitStatus::Clean),
lint: LintCell::hidden(),
disk: "—",
disk_style: Style::default(),
disk_suffix: None,
disk_suffix_style: None,
lang_icon: "🦀",
git_origin_sync: "",
git_main: "",
ci: None,
deleted: false,
worktree_health: WorktreeHealth::Normal,
});
assert_eq!(
clean.cells[COL_GIT_PATH].text,
crate::constants::GIT_STATUS_CLEAN
);
let ignored = build_row_cells(ProjectRow {
prefix: " ",
name: "demo",
name_segments: None,
git_status: Some(GitStatus::Ignored),
lint: LintCell::hidden(),
disk: "—",
disk_style: Style::default(),
disk_suffix: None,
disk_suffix_style: None,
lang_icon: "🦀",
git_origin_sync: "",
git_main: "",
ci: None,
deleted: false,
worktree_health: WorktreeHealth::Normal,
});
assert_eq!(
ignored.cells[COL_NAME].style.fg,
Some(theme_roles::git_ignored_color())
);
assert!(ignored.cells[COL_GIT_PATH].text.is_empty());
}
#[test]
fn build_shimmer_segments_wraps_around_name_end() {
let segments = build_shimmer_segments(
"abcd",
Style::default(),
Style::default().fg(title_color()),
3,
2,
);
let actual: Vec<_> = segments
.iter()
.map(|segment| (segment.text.as_str(), segment.style.fg))
.collect();
assert_eq!(
actual,
vec![
("a", Some(title_color())),
("bc", None),
("d", Some(title_color())),
]
);
}
#[test]
fn shimmer_style_never_uses_bold() {
for state in [
Some(GitStatus::Clean),
Some(GitStatus::Modified),
Some(GitStatus::Untracked),
Some(GitStatus::Ignored),
None,
] {
assert!(
!project_name_shimmer_style(state)
.add_modifier
.contains(Modifier::BOLD)
);
}
}
#[test]
fn clean_shimmer_style_uses_explicit_high_contrast_foreground() {
assert_eq!(
project_name_shimmer_style(Some(GitStatus::Clean)).fg,
Some(theme_roles::discovery_shimmer_color())
);
assert_eq!(
project_name_shimmer_style(None).fg,
Some(theme_roles::discovery_shimmer_color())
);
}
}