use crate::interactive::widgets::COUNT;
use crate::interactive::widgets::tui_ext::util::rect::line_bound;
use crate::interactive::widgets::tui_ext::{
List, ListProps, draw_text_nowrap_fn,
util::{block_width, rect},
};
use crate::interactive::{
DisplayOptions, EntryDataBundle, SortMode,
widgets::{EntryMarkMap, entry_color},
};
use chrono::DateTime;
use dua::traverse::TreeIndex;
use itertools::Itertools;
use std::borrow::{Borrow, Cow};
use std::collections::{BTreeSet, HashSet};
use std::path::{Component, Path, PathBuf};
use std::time::SystemTime;
use tui::{
buffer::Buffer,
layout::{Margin, Rect},
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, Borders, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub struct EntriesProps<'a> {
pub current_path: PathBuf,
pub display: DisplayOptions,
pub selected: Option<TreeIndex>,
pub entries: &'a [EntryDataBundle],
pub marked: Option<&'a EntryMarkMap>,
pub cleanup_candidates: Option<&'a BTreeSet<TreeIndex>>,
pub gitignored_entries: Option<&'a BTreeSet<TreeIndex>>,
pub border_style: Style,
pub is_focussed: bool,
pub sort_mode: SortMode,
pub show_columns: &'a HashSet<Column>,
}
#[derive(Default)]
pub struct Entries {
pub list: List,
}
impl Entries {
pub fn render<'a>(
&mut self,
props: impl Borrow<EntriesProps<'a>>,
area: Rect,
buf: &mut Buffer,
) {
let EntriesProps {
current_path,
display,
entries,
selected,
marked,
cleanup_candidates,
gitignored_entries,
border_style,
is_focussed,
sort_mode,
show_columns,
} = props.borrow();
let list = &mut self.list;
let total: u128 = entries.iter().map(|b| b.size).sum();
let (recursive_item_count, item_size): (u64, u128) = entries
.iter()
.map(|f| (f.entry_count.unwrap_or(1), f.size))
.reduce(|a, b| (a.0 + b.0, a.1 + b.1))
.unwrap_or_default();
let title_width_inside_borders = area.width.saturating_sub(2) as usize;
let title = title(
current_path,
entries.len(),
recursive_item_count,
*display,
item_size,
title_width_inside_borders,
);
let title_block = title_block(&title, *border_style);
let inner_area = title_block.inner(area);
let entry_in_view = entry_in_view(*selected, entries);
let props = ListProps {
block: Some(title_block),
entry_in_view,
};
let mut scroll_offset = None;
let lines = entries.iter().enumerate().map(|(idx, bundle)| {
let node_idx = &bundle.index;
let is_dir = &bundle.is_dir;
let exists = &bundle.exists;
let name = bundle.name.as_path();
let is_marked = marked.map(|m| m.contains_key(node_idx)).unwrap_or(false);
let is_cleanup_candidate = cleanup_candidates.is_some_and(|c| c.contains(node_idx));
let is_gitignored = gitignored_entries.is_some_and(|g| g.contains(node_idx));
let is_selected = selected == &Some(*node_idx);
if is_selected {
scroll_offset = Some(idx);
}
let fraction = bundle.size as f32 / total as f32;
let text_style = style(is_selected, *is_focussed);
let percentage_style = percentage_style(fraction, text_style);
let mut columns = Vec::new();
if show_mtime_column(sort_mode, show_columns) {
columns.push(mtime_column(
bundle.mtime,
column_style(Column::MTime, *sort_mode, text_style),
));
}
columns.push(bytes_column(
*display,
bundle.size,
column_style(Column::Bytes, *sort_mode, text_style),
));
columns.push(percentage_column(*display, fraction, percentage_style));
if show_count_column(sort_mode, show_columns) {
columns.push(count_column(
bundle.entry_count,
column_style(Column::Count, *sort_mode, text_style),
));
}
let available_width = inner_area.width.saturating_sub(
columns_with_separators(columns.clone(), percentage_style, true)
.iter()
.map(|f| f.width() as u16)
.sum(),
) as usize;
let name = shorten_input(
name_with_prefix(name.to_string_lossy(), *is_dir),
available_width,
);
let style = name_style(
is_marked,
is_cleanup_candidate,
is_gitignored,
*exists,
*is_dir,
text_style,
);
columns.push(name_column(name, area, style));
columns_with_separators(columns, percentage_style, false)
});
let line_count = lines.len();
list.render(props, lines, area, buf);
let scrollbar = Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None);
let mut scrollbar_state =
ScrollbarState::new(line_count).position(scroll_offset.unwrap_or(list.offset));
scrollbar.render(area.inner(&Margin::new(0, 1)), buf, &mut scrollbar_state);
if *is_focussed {
let bound = draw_top_right_help(area, &title, buf);
draw_bottom_right_help(bound, buf);
}
}
}
fn entry_in_view(
selected: Option<petgraph::stable_graph::NodeIndex>,
entries: &[EntryDataBundle],
) -> Option<usize> {
selected.map(|selected| {
entries
.iter()
.find_position(|b| b.index == selected)
.map(|(idx, _)| idx)
.unwrap_or(0)
})
}
fn title_block(title: &str, border_style: Style) -> Block<'_> {
Block::default()
.title(title)
.border_style(border_style)
.borders(Borders::ALL)
}
fn title(
current_path: &Path,
item_count: usize,
recursive_item_count: u64,
display: DisplayOptions,
size: u128,
title_width_cells: usize,
) -> String {
let statistics = format!(
"({item_count} visible, {} total, {})",
COUNT.format(recursive_item_count as f64),
display.byte_format.display(size)
);
let title = format!(" {path} {statistics} ", path = current_path.display());
if title.width() <= title_width_cells {
return title;
}
path_title(current_path, title_width_cells)
}
fn path_title(current_path: &Path, width: usize) -> String {
let padding = 2;
match width {
0 => String::new(),
width if width <= padding => {
shorten_input(current_path.to_string_lossy(), width).into_owned()
}
width => {
let path = compact_path(current_path, width - padding);
let title = format!(" {path} ");
if title.width() <= width {
title
} else {
shorten_input(Cow::Owned(title), width).into_owned()
}
}
}
}
fn compact_path(current_path: &Path, width: usize) -> Cow<'_, str> {
let current_path_display = current_path.to_string_lossy();
if current_path_display.width() <= width {
return current_path_display;
}
let Some(path) = DisplayPath::new(current_path) else {
return shorten_input(current_path_display, width);
};
let keep_ends = true;
path.compact_by_removing_components(width, keep_ends)
.or_else(|| path.compact_by_removing_components(width, !keep_ends))
.map(Cow::Owned)
.unwrap_or_else(|| shorten_input(current_path_display, width))
}
struct DisplayPath<'a> {
prefix: String,
separator: char,
components: Vec<Cow<'a, str>>,
component_width_prefix_sum: Vec<usize>,
width: usize,
}
impl<'a> DisplayPath<'a> {
fn new(path: &'a Path) -> Option<Self> {
let separator = std::path::MAIN_SEPARATOR;
let mut prefix = String::new();
let mut components = Vec::new();
for component in path.components() {
match component {
Component::Prefix(prefix_component) => {
prefix.push_str(&prefix_component.as_os_str().to_string_lossy());
}
Component::RootDir => {
prefix.push(separator);
}
Component::CurDir => components.push(Cow::Borrowed(".")),
Component::ParentDir => components.push(Cow::Borrowed("..")),
Component::Normal(component) => {
components.push(component.to_string_lossy());
}
}
}
if components.len() < 2 {
return None;
}
let mut component_width_prefix_sum = Vec::with_capacity(components.len() + 1);
component_width_prefix_sum.push(0);
for component in &components {
component_width_prefix_sum
.push(component_width_prefix_sum.last().copied().unwrap() + component.width());
}
let width = prefix.width()
+ component_width_prefix_sum.last().copied().unwrap()
+ separator.width().unwrap_or(1) * (components.len() - 1);
Some(DisplayPath {
prefix,
separator,
components,
component_width_prefix_sum,
width,
})
}
fn compact_by_removing_components(&self, width: usize, keep_ends: bool) -> Option<String> {
let component_count = self.components.len();
let path_center = component_count as isize - 1;
let (first_start, last_start) = if keep_ends {
(1, component_count.checked_sub(2)?)
} else {
(0, component_count.checked_sub(1)?)
};
if last_start == 0 {
return None;
}
let mut best = None;
for start in first_start..=last_start {
let first_end = start + 1;
let last_end = if keep_ends {
component_count - 1
} else {
component_count
};
if first_end > last_end || self.candidate_width(start, last_end) > width {
continue;
}
let mut lower = first_end;
let mut upper = last_end;
while lower < upper {
let middle = lower + (upper - lower) / 2;
if self.candidate_width(start, middle) <= width {
upper = middle;
} else {
lower = middle + 1;
}
}
let end = lower;
let removed_components = end - start;
let removed_center = (start * 2 + removed_components - 1) as isize;
let center_distance = (removed_center - path_center).abs();
let candidate_width = self.candidate_width(start, end);
if best.as_ref().is_none_or(
|(_, _, best_removed_components, best_distance, best_width)| {
(removed_components, center_distance, candidate_width)
< (*best_removed_components, *best_distance, *best_width)
},
) {
best = Some((
start,
end,
removed_components,
center_distance,
candidate_width,
));
}
}
best.map(|(start, end, _, _, _)| self.candidate(start, end))
}
fn candidate_width(&self, start: usize, end: usize) -> usize {
fn marker_width(removed_components: usize) -> usize {
2 + removed_components.ilog10() as usize + 1
}
let removed_components = end - start;
let removed_component_width =
self.component_width_prefix_sum[end] - self.component_width_prefix_sum[start];
let removed_separator_width = self.separator.width().unwrap_or(1)
* ((removed_components - 1)
+ usize::from(start > 0)
+ usize::from(end < self.components.len()));
self.width - removed_component_width - removed_separator_width
+ marker_width(removed_components)
}
fn candidate(&self, start: usize, end: usize) -> String {
fn push_components(out: &mut String, separator: char, components: &[Cow<'_, str>]) {
for (idx, component) in components.iter().enumerate() {
if idx > 0 {
out.push(separator);
}
out.push_str(component);
}
}
let mut out = String::new();
out.push_str(&self.prefix);
push_components(&mut out, self.separator, &self.components[..start]);
let removed_components = end - start;
out.push_str(&format!("[{removed_components}]"));
push_components(&mut out, self.separator, &self.components[end..]);
out
}
}
fn draw_bottom_right_help(bound: Rect, buf: &mut Buffer) {
let bound = line_bound(bound, bound.height.saturating_sub(1) as usize);
let mut help_text = " mark-move = d | mark-toggle = space | cleanup = X".to_string();
help_text.push_str(" | gitignore = I");
help_text.push_str(" | all = a ");
let help_text_block_width = block_width(&help_text);
if help_text_block_width <= bound.width {
draw_text_nowrap_fn(
rect::snap_to_right(bound, help_text_block_width),
buf,
&help_text,
|_, _, _| Style::default(),
);
}
}
fn draw_top_right_help(area: Rect, title: &str, buf: &mut Buffer) -> Rect {
let help_text = " . = o|.. = u ── ⇊ = Ctrl+d|↓ = j|⇈ = Ctrl+u|↑ = k ";
let help_text_block_width = block_width(help_text);
let bound = Rect {
width: area.width.saturating_sub(1),
..area
};
if block_width(title) + help_text_block_width <= bound.width {
draw_text_nowrap_fn(
rect::snap_to_right(bound, help_text_block_width),
buf,
help_text,
|_, _, _| Style::default(),
);
}
bound
}
fn style(is_selected: bool, is_focussed: bool) -> Style {
let mut style = Style::default();
if is_selected {
style.add_modifier.insert(Modifier::REVERSED);
}
if is_focussed & is_selected {
style.add_modifier.insert(Modifier::BOLD);
}
style
}
fn percentage_style(fraction: f32, style: Style) -> Style {
let avoid_big_reversed_bar = fraction > 0.9;
if avoid_big_reversed_bar {
style.remove_modifier(Modifier::REVERSED)
} else {
style
}
}
fn columns_with_separators(
columns: Vec<Span<'_>>,
style: Style,
insert_last_separator: bool,
) -> Vec<Span<'_>> {
let mut columns_with_separators = Vec::new();
let column_count = columns.len();
for (idx, column) in columns.into_iter().enumerate() {
columns_with_separators.push(column);
if insert_last_separator || idx != column_count - 1 {
columns_with_separators.push(Span::styled(" | ", style))
}
}
columns_with_separators
}
fn mtime_column(entry_mtime: SystemTime, style: Style) -> Span<'static> {
let datetime = DateTime::<chrono::Utc>::from(entry_mtime);
let formatted_time = datetime.format("%d/%m/%Y %H:%M:%S").to_string();
Span::styled(format!("{formatted_time:>20}"), style)
}
fn count_column(entry_count: Option<u64>, style: Style) -> Span<'static> {
Span::styled(
format!(
"{:>4}",
match entry_count {
Some(count) => {
COUNT.format(count as f64)
}
None => "".to_string(),
}
),
style,
)
}
fn name_column(name: Cow<'_, str>, area: Rect, style: Style) -> Span<'_> {
Span::styled(fill_background_to_right(name, area.width), style)
}
fn fill_background_to_right(mut s: Cow<'_, str>, entire_width: u16) -> Cow<'_, str> {
match (s.len(), entire_width as usize) {
(x, y) if x >= y => s,
(x, y) => {
s.to_mut().extend(std::iter::repeat_n(' ', y - x));
s
}
}
}
fn name_with_prefix(mut name: Cow<'_, str>, is_dir: bool) -> Cow<'_, str> {
let prefix = if is_dir {
if name == "."
|| name == ".."
|| name.starts_with('/')
|| name.starts_with("./")
|| name.starts_with("../")
{
None
} else {
Some("/")
}
} else {
Some(" ")
};
match prefix {
None => name,
Some(prefix) => {
name.to_mut().insert_str(0, prefix);
name
}
}
}
fn name_style(
is_marked: bool,
is_cleanup_candidate: bool,
is_gitignored: bool,
exists: bool,
is_dir: bool,
style: Style,
) -> Style {
let mut style = style;
let fg = if !exists {
Some(Color::Red)
} else if is_cleanup_candidate && !is_marked {
Some(Color::Magenta)
} else {
entry_color(style.fg, !is_dir, is_marked)
};
if is_gitignored && !is_marked && exists {
style.add_modifier.insert(Modifier::DIM);
}
Style { fg, ..style }
}
fn percentage_column(display: DisplayOptions, fraction: f32, style: Style) -> Span<'static> {
Span::styled(format!("{}", display.byte_vis.display(fraction)), style)
}
fn bytes_column(display: DisplayOptions, entry_size: u128, style: Style) -> Span<'static> {
Span::styled(
format!(
"{:>byte_column_width$}",
display.byte_format.display(entry_size).to_string(), byte_column_width = display.byte_format.width()
),
style,
)
}
#[derive(PartialEq, Eq, Hash)]
pub enum Column {
Bytes,
MTime,
Count,
}
fn column_style(column: Column, sort_mode: SortMode, style: Style) -> Style {
Style {
fg: match (sort_mode, column) {
(SortMode::SizeAscending | SortMode::SizeDescending, Column::Bytes)
| (SortMode::MTimeAscending(_) | SortMode::MTimeDescending(_), Column::MTime)
| (SortMode::CountAscending | SortMode::CountDescending, Column::Count) => {
Color::Green.into()
}
_ => style.fg,
},
..style
}
}
fn show_mtime_column(sort_mode: &SortMode, show_columns: &HashSet<Column>) -> bool {
matches!(
sort_mode,
SortMode::MTimeAscending(_) | SortMode::MTimeDescending(_)
) || show_columns.contains(&Column::MTime)
}
fn show_count_column(sort_mode: &SortMode, show_columns: &HashSet<Column>) -> bool {
matches!(
sort_mode,
SortMode::CountAscending | SortMode::CountDescending
) || show_columns.contains(&Column::Count)
}
fn shorten_input(input: Cow<'_, str>, width: usize) -> Cow<'_, str> {
const ELLIPSIS: char = '…';
const EXTENDED: bool = true;
if input.width() <= width {
return input;
}
let ellipsis_width = ELLIPSIS.width().unwrap_or(1);
if ellipsis_width > width {
return Cow::Borrowed("");
}
if width == ellipsis_width {
return Cow::Owned(ELLIPSIS.to_string());
}
let retained_width = width - ellipsis_width;
if retained_width < 2 {
return Cow::Owned(ELLIPSIS.to_string());
}
let prefix_width = retained_width / 2 + retained_width % 2;
let suffix_width = retained_width / 2;
let graphemes: Vec<_> = input.graphemes(EXTENDED).collect();
let prefix_end = grapheme_boundary_fitting_width(
graphemes
.iter()
.enumerate()
.map(|(idx, grapheme)| (idx + 1, *grapheme)),
0,
prefix_width,
);
let suffix_start = grapheme_boundary_fitting_width(
(prefix_end..graphemes.len())
.rev()
.map(|idx| (idx, graphemes[idx])),
graphemes.len(),
suffix_width,
);
let split_budget_fits_no_graphemes = prefix_end == 0 && suffix_start == graphemes.len();
let (prefix_end, suffix_start) = if split_budget_fits_no_graphemes {
(
grapheme_boundary_fitting_width(
graphemes
.iter()
.enumerate()
.map(|(idx, grapheme)| (idx + 1, *grapheme)),
0,
retained_width,
),
graphemes.len(),
)
} else {
(prefix_end, suffix_start)
};
let mut out = String::new();
out.extend(graphemes[..prefix_end].iter().copied());
out.push(ELLIPSIS);
out.extend(graphemes[suffix_start..].iter().copied());
Cow::Owned(out)
}
fn grapheme_boundary_fitting_width<'a>(
graphemes: impl IntoIterator<Item = (usize, &'a str)>,
initial_boundary: usize,
width: usize,
) -> usize {
let mut used_width = 0;
let mut boundary = initial_boundary;
for (next_boundary, grapheme) in graphemes {
let grapheme_width = grapheme.width();
if used_width + grapheme_width > width {
break;
}
used_width += grapheme_width;
boundary = next_boundary;
}
boundary
}
#[cfg(test)]
mod entries_test {
use std::collections::HashSet;
use std::path::Path;
use super::{name_style, shorten_input, show_mtime_column, title as entry_title};
use crate::interactive::widgets::Column;
use crate::interactive::{MTimeSort, SortMode};
use dua::ByteFormat;
use tui::style::{Color, Modifier, Style};
use unicode_width::UnicodeWidthStr;
#[test]
fn test_shorten_string_middle() {
let numbers = "12345678";
let graphemes = "你好😁世界";
for (input, target_width, expected) in [
(numbers, 8, numbers),
(numbers, 7, "123…678"),
(numbers, 3, "1…8"),
(numbers, 2, "…"),
(numbers, 1, "…"),
(numbers, 0, ""),
(graphemes, 0, ""),
(graphemes, 1, "…"),
(graphemes, 3, "你…"),
(graphemes, 4, "你…"),
(graphemes, 5, "你…界"),
(graphemes, 6, "你…界"),
(graphemes, 7, "你…界"),
(graphemes, 8, "你好…界"),
(graphemes, 9, "你好…世界"),
(graphemes, 10, "你好😁世界"),
] {
let actual = shorten_input(input.into(), target_width);
assert_eq!(actual, expected);
assert!(actual.as_ref().width() <= target_width);
}
}
#[test]
fn title_drops_statistics_before_shortening_path() {
let path = "a/b/c";
assert_eq!(title(path, path.len() + 2), " a/b/c ");
}
#[test]
fn title_shows_stats_if_possible() {
assert_eq!(
title("项目/资料", 42),
" 项目/资料 (4 visible, 43 total, 1.42 GB) "
);
}
#[test]
fn title_drops_statistics_for_wide_unicode_path() {
assert_eq!(title("项目/资料", 41), " 项目/资料 ");
}
#[test]
fn title_degrades_path_by_removing_fewest_consecutive_components() {
assert_eq!(title("a/b/c/d", 8), " a[2]d ");
}
#[test]
fn title_degrades_unicode_path_components_by_display_width() {
assert_eq!(title("项目/😀😀😀😀/资料/📦", 12), " 项目[2]📦 ");
}
#[test]
#[cfg(unix)]
fn title_preserves_root_dir() {
assert_eq!(title("/a/b/c/d", 9), " /a[2]d ");
}
#[test]
#[cfg(windows)]
fn title_degrades_backslash_separated_paths() {
assert_eq!(title("a\\b\\c\\d", 8), " a[2]d ");
}
#[test]
#[cfg(unix)]
fn title_keeps_unix_backslashes_as_component_text() {
assert_eq!(title("foo\\bar/baz/qux", 15), " foo\\bar[1]qux ");
}
#[test]
fn title_treats_drive_like_text_as_relative_component() {
assert_eq!(title("C:\\long/bar/baz/qux", 17), " C:\\long[2]qux ");
}
#[test]
#[cfg(unix)]
fn path_degradation_selects_the_shortest_sufficient_run() {
assert_eq!(
title("short/very-long-component/tiny/end", 19),
" short[1]tiny/end "
);
}
fn title(path: impl AsRef<Path>, title_width_cells: usize) -> String {
let display = crate::interactive::DisplayOptions::new(ByteFormat::Metric);
entry_title(
path.as_ref(),
4,
43,
display,
1_420_000_000,
title_width_cells,
)
}
#[test]
fn sorting_by_mtime_shows_column_like_count_sorting() {
let mut show_columns = HashSet::new();
assert!(
show_mtime_column(&SortMode::MTimeDescending(MTimeSort::Entry), &show_columns,),
"mtime sorting shows the mtime column even when it is not explicitly enabled",
);
show_columns.insert(Column::MTime);
assert!(
show_mtime_column(&SortMode::SizeDescending, &show_columns,),
"explicitly enabling the mtime column shows it for non-mtime sorts",
);
}
#[test]
fn name_style_prioritizes_missing_gitignored_and_cleanup_states() {
let style = Style::default();
let is_marked = false;
let is_cleanup_candidate = true;
let is_gitignored = true;
let exists = true;
let is_dir = true;
assert_eq!(
name_style(
is_marked,
is_cleanup_candidate,
is_gitignored,
!exists,
is_dir,
style
)
.fg,
Some(Color::Red),
"missing entries stay red"
);
let gitignored = name_style(
is_marked,
!is_cleanup_candidate,
is_gitignored,
exists,
is_dir,
style,
);
assert_eq!(
gitignored.fg,
Some(Color::Cyan),
"gitignored entries keep the regular directory color"
);
assert!(gitignored.add_modifier.contains(Modifier::DIM));
let cleanup = name_style(
is_marked,
is_cleanup_candidate,
!is_gitignored,
exists,
is_dir,
style,
);
assert_eq!(
cleanup.fg,
Some(Color::Magenta),
"cleanup candidates use a distinct foreground color"
);
assert!(
!cleanup.add_modifier.contains(Modifier::DIM),
"cleanup candidates are colored without dimming unless they are gitignored"
);
}
}