use std::path::Path;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, List, ListItem, ListState};
use ratatui::Frame;
use crate::app::state::{AppState, Entry, EntryKind, GitFileStatus};
use crate::metafmt;
use crate::pathfmt;
use crate::preview::provider;
use crate::ui::theme;
const MIN_NAME_WIDTH: usize = 12;
pub const ENTRY_DETAILS_REASON: &str = "must be none, size, modified or both";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EntryDetails {
#[default]
None,
Size,
Modified,
Both,
}
impl EntryDetails {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"none" | "off" => Some(Self::None),
"size" => Some(Self::Size),
"modified" | "time" => Some(Self::Modified),
"both" => Some(Self::Both),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Size => "size",
Self::Modified => "modified",
Self::Both => "both",
}
}
pub fn next(self) -> Self {
match self {
Self::None => Self::Size,
Self::Size => Self::Modified,
Self::Modified => Self::Both,
Self::Both => Self::None,
}
}
fn fit(self, row_width: usize) -> Option<DetailsLayout> {
const GAP: usize = 1;
let candidates: &[(usize, bool)] = match self {
Self::None => return None,
Self::Size => &[(SIZE_WIDTH, false)],
Self::Modified => &[
(metafmt::MODIFIED_WIDTH, false),
(metafmt::MODIFIED_SHORT_WIDTH, true),
],
Self::Both => &[
(SIZE_WIDTH + 1 + metafmt::MODIFIED_WIDTH, false),
(SIZE_WIDTH + 1 + metafmt::MODIFIED_SHORT_WIDTH, true),
],
};
for &(width, short_date) in candidates {
let name_width = row_width.saturating_sub(width + GAP);
if name_width >= MIN_NAME_WIDTH {
return Some(DetailsLayout {
kind: self,
width,
name_width,
short_date,
});
}
}
None
}
}
const SIZE_WIDTH: usize = 9;
#[derive(Debug, Clone, Copy)]
struct DetailsLayout {
kind: EntryDetails,
width: usize,
name_width: usize,
short_date: bool,
}
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState, offset: usize) {
let styles = theme::resolve(&state.config.theme);
let title = format!(" {} ", panel_title(&state.cwd));
let row_width = usize::from(area.width)
.saturating_sub(2)
.saturating_sub(HIGHLIGHT_SYMBOL.len());
let details = state.config.navigation.details().fit(row_width);
let range = crate::app::marks::visual_bounds(state);
let items: Vec<ListItem> = state
.filtered_entries()
.enumerate()
.map(|(row, (_, entry))| {
let base_style = match entry.kind {
EntryKind::Dir => {
let s = styles.directory;
if entry.is_hidden {
s.patch(styles.hidden)
} else {
s
}
}
EntryKind::Symlink => {
let s = styles.symlink;
if entry.is_hidden {
s.patch(styles.hidden)
} else {
s
}
}
EntryKind::File => {
let s = styles.normal;
if entry.is_hidden {
s.patch(styles.hidden)
} else {
s
}
}
};
let name = provider::sanitize(&entry.file_name);
let label = if entry.kind == EntryKind::Dir {
format!("{name}/")
} else {
name
};
let git_span = match &entry.git_status {
Some(GitFileStatus::Modified) => Some(Span::styled(" M", styles.git_dirty)),
Some(GitFileStatus::Added) => Some(Span::styled(" A", styles.git_clean)),
Some(GitFileStatus::Deleted) => Some(Span::styled(
" D",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Some(GitFileStatus::Untracked) => {
Some(Span::styled(" ?", Style::default().fg(Color::DarkGray)))
}
Some(GitFileStatus::Renamed) => {
Some(Span::styled(" R", Style::default().fg(Color::Cyan)))
}
Some(GitFileStatus::Clean) | None => None,
};
let mut spans = if state.marks.contains(&entry.path) {
vec![
Span::styled(MARK_SYMBOL, styles.command.add_modifier(Modifier::BOLD)),
Span::styled(label, base_style.add_modifier(Modifier::BOLD)),
]
} else {
vec![Span::styled(format!(" {label}"), base_style)]
};
if let Some(badge) = git_span {
spans.push(badge);
}
if let Some(layout) = details {
let used = truncate_spans(&mut spans, layout.name_width);
let pad = layout.name_width.saturating_sub(used) + 1;
spans.push(Span::raw(" ".repeat(pad)));
spans.push(Span::styled(
format!(
"{:>width$}",
details_text(entry, layout),
width = layout.width
),
styles.status,
));
}
let item = ListItem::new(Line::from(spans));
match range {
Some((first, last)) if (first..=last).contains(&row) => {
item.style(styles.selection)
}
_ => item,
}
})
.collect();
let block = Block::default()
.title(title.clone())
.borders(Borders::ALL)
.border_style(styles.border)
.border_type(BorderType::Rounded);
let block = match fit_badge(
state.sort,
title.chars().count(),
usize::from(area.width).saturating_sub(2),
) {
Some(badge) => block.title_top(
Line::from(Span::styled(format!(" {badge} "), styles.status)).right_aligned(),
),
None => block,
};
let inner_width = usize::from(area.width).saturating_sub(2);
let path = pathfmt::elide_front(&state.status.cwd_display, inner_width.saturating_sub(2));
let block = if path.is_empty() {
block
} else {
block.title_bottom(Line::from(Span::styled(format!(" {path} "), styles.status)))
};
let list = List::new(items)
.block(block)
.highlight_style(styles.selection)
.highlight_symbol(HIGHLIGHT_SYMBOL);
let mut list_state = ListState::default().with_offset(offset);
if state.filtered_count() > 0 {
list_state.select(Some(state.selected));
}
frame.render_stateful_widget(list, area, &mut list_state);
}
fn sort_badge(settings: crate::app::sort::SortSettings) -> String {
let arrow = if settings.is_descending() {
'↓'
} else {
'↑'
};
let mut out = format!("{}{arrow}", settings.by.short_label());
if !settings.dirs_first {
out.push_str(" mixed");
}
out
}
fn fit_badge(
settings: crate::app::sort::SortSettings,
title_width: usize,
inner_width: usize,
) -> Option<String> {
let full = sort_badge(settings);
let room = inner_width.saturating_sub(title_width);
for candidate in [full.as_str(), full.split(' ').next().unwrap_or(&full)] {
if candidate.chars().count() + 3 <= room {
return Some(candidate.to_owned());
}
}
None
}
const HIGHLIGHT_SYMBOL: &str = "> ";
const MARK_SYMBOL: &str = "*";
fn details_text(entry: &Entry, layout: DetailsLayout) -> String {
let size = || {
entry
.metadata
.as_ref()
.filter(|_| entry.kind != EntryKind::Dir)
.map(|m| metafmt::size(m.len()))
.unwrap_or_else(|| metafmt::UNKNOWN.to_owned())
};
let time = || {
let t = entry.metadata.as_ref().and_then(|m| m.modified().ok());
if layout.short_date {
metafmt::modified_short(t)
} else {
metafmt::modified(t)
}
};
match layout.kind {
EntryDetails::None => String::new(),
EntryDetails::Size => size(),
EntryDetails::Modified => time(),
EntryDetails::Both => {
let time_width = if layout.short_date {
metafmt::MODIFIED_SHORT_WIDTH
} else {
metafmt::MODIFIED_WIDTH
};
let size_width = SIZE_WIDTH;
format!("{:>size_width$} {:>time_width$}", size(), time())
}
}
}
fn truncate_spans(spans: &mut Vec<Span<'static>>, budget: usize) -> usize {
let total: usize = spans.iter().map(|s| s.content.chars().count()).sum();
if total <= budget {
return total;
}
let mut remaining = budget;
let mut kept: Vec<Span<'static>> = Vec::with_capacity(spans.len());
for span in spans.drain(..) {
let len = span.content.chars().count();
if len <= remaining {
remaining -= len;
kept.push(span);
continue;
}
if remaining > 0 {
let head: String = span
.content
.chars()
.take(remaining.saturating_sub(1))
.collect();
kept.push(Span::styled(format!("{head}…"), span.style));
}
break;
}
*spans = kept;
spans.iter().map(|s| s.content.chars().count()).sum()
}
fn panel_title(cwd: &Path) -> String {
cwd.file_name()
.and_then(|name| name.to_str())
.map(str::to_owned)
.unwrap_or_else(|| pathfmt::display(cwd))
}
#[cfg(test)]
mod tests {
use super::*;
fn plain(by: crate::app::sort::SortBy) -> crate::app::sort::SortSettings {
crate::app::sort::SortSettings {
by,
reverse: false,
dirs_first: true,
}
}
#[test]
fn the_badge_names_the_key_and_the_direction() {
use crate::app::sort::SortBy;
assert_eq!(sort_badge(plain(SortBy::Name)), "name↑");
assert_eq!(sort_badge(plain(SortBy::Size)), "size↓");
assert_eq!(sort_badge(plain(SortBy::Modified)), "time↓");
assert_eq!(sort_badge(plain(SortBy::Extension)), "ext↑");
}
#[test]
fn reversing_flips_the_badge_arrow() {
use crate::app::sort::{SortBy, SortSettings};
let reversed = SortSettings {
reverse: true,
..plain(SortBy::Size)
};
assert_eq!(sort_badge(reversed), "size↑");
}
#[test]
fn the_badge_says_mixed_only_when_directories_are_not_grouped() {
use crate::app::sort::{SortBy, SortSettings};
assert_eq!(sort_badge(plain(SortBy::Name)), "name↑");
let mixed = SortSettings {
dirs_first: false,
..plain(SortBy::Name)
};
assert_eq!(sort_badge(mixed), "name↑ mixed");
}
#[test]
fn a_narrow_border_drops_mixed_before_it_drops_the_badge() {
use crate::app::sort::{SortBy, SortSettings};
let mixed = SortSettings {
dirs_first: false,
..plain(SortBy::Size)
};
let fitted = fit_badge(mixed, 8, 18).expect("the short form fits");
assert_eq!(fitted, "size↓");
}
#[test]
fn a_border_with_no_room_drops_the_badge_entirely() {
use crate::app::sort::SortBy;
assert!(fit_badge(plain(SortBy::Size), 20, 22).is_none());
}
#[test]
fn details_parse_round_trips_every_value() {
for value in ["none", "size", "modified", "both"] {
let parsed = EntryDetails::parse(value).expect("canonical spelling parses");
assert_eq!(parsed.as_str(), value);
}
assert_eq!(EntryDetails::parse("off"), Some(EntryDetails::None));
assert_eq!(EntryDetails::parse("bogus"), None);
}
#[test]
fn details_cycle_returns_to_none() {
let mut d = EntryDetails::None;
for _ in 0..4 {
d = d.next();
}
assert_eq!(d, EntryDetails::None, "`m` four times is a no-op");
}
#[test]
fn none_never_takes_a_column() {
assert!(EntryDetails::None.fit(200).is_none());
}
#[test]
fn a_size_column_fits_an_eighty_column_terminal() {
let layout = EntryDetails::Size.fit(28).expect("size fits at 80 columns");
assert_eq!(layout.width, SIZE_WIDTH);
assert_eq!(layout.name_width, 28 - SIZE_WIDTH - 1);
assert!(!layout.short_date);
}
#[test]
fn both_stands_down_rather_than_crush_the_name() {
assert!(EntryDetails::Both.fit(28).is_none());
}
#[test]
fn both_drops_the_clock_before_it_drops_the_column() {
let width = SIZE_WIDTH + 1 + metafmt::MODIFIED_SHORT_WIDTH + 1 + MIN_NAME_WIDTH;
let layout = EntryDetails::Both.fit(width).expect("the short form fits");
assert!(layout.short_date);
assert_eq!(layout.name_width, MIN_NAME_WIDTH);
}
#[test]
fn a_name_budget_below_the_floor_gives_up_the_column() {
let just_under = SIZE_WIDTH + 1 + (MIN_NAME_WIDTH - 1);
assert!(EntryDetails::Size.fit(just_under).is_none());
assert!(EntryDetails::Size.fit(just_under + 1).is_some());
}
#[test]
fn truncation_marks_what_it_dropped_and_respects_the_budget() {
let mut spans = vec![
Span::raw(" a-very-long-file-name.txt".to_owned()),
Span::raw(" M".to_owned()),
];
let used = truncate_spans(&mut spans, 10);
assert_eq!(used, 10, "the budget is filled exactly");
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.ends_with('…'), "a cut has to be visible: {text:?}");
}
#[test]
fn truncation_leaves_something_that_already_fits_alone() {
let mut spans = vec![Span::raw(" short.txt".to_owned())];
let used = truncate_spans(&mut spans, 40);
assert_eq!(used, 10);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content.as_ref(), " short.txt");
}
#[test]
fn title_is_the_directory_name() {
assert_eq!(panel_title(Path::new("/home/me/project")), "project");
}
#[cfg(windows)]
#[test]
fn title_is_the_directory_name_for_a_windows_path() {
assert_eq!(panel_title(Path::new(r"C:\Users\me\project")), "project");
}
#[test]
fn title_of_a_root_is_the_root() {
assert_eq!(panel_title(Path::new("/")), "/");
}
}