W37: Size distribution histogram in panel footer

Add a 10-cell log2-bucketed histogram showing file-size
distribution across the directory. Each cell is the count of
files in a bucket (1B..1KiB, 1KiB..2KiB, ... >=512GiB) scaled to
a 0..8 block-char level. Rendered in accent colour next to
the existing per-file size bar.

- size_histogram() returns [u64; 10] counts per bucket
- Uses u64 leading_zeros trick for O(1) log2 size
- Skips directories and the '..' entry
- Directories are excluded from the histogram
- Renders after the cursor file's size bar

Added 1 unit test verifying bucket placement for 2B and 16KiB.

Tests: 1400 pass (was 1399), zero warnings.
This commit is contained in:
2026-07-06 23:23:00 +03:00
parent 00c0eaa6b6
commit e7d5dad0aa
@@ -503,6 +503,27 @@ impl FileManager {
));
}
}
// Mini-histogram of file-size distribution across
// the directory. Each cell is the count of files in
// a log2 bucket. 10 cells cover 1B..1TB.
let histogram = size_histogram(panel);
let max_bucket = histogram.iter().copied().max().unwrap_or(1).max(1);
let hist_w = 10usize;
let mut hist_str = String::with_capacity(hist_w);
for &count in &histogram[..hist_w] {
let level = if count == 0 {
0
} else {
// Scale count/max to 0..8 (8 block-char levels).
((count as u64 * 8 + max_bucket / 2) / max_bucket).min(8)
};
hist_str.push(['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'][level as usize]);
}
footer_spans.push(Span::raw(" "));
footer_spans.push(Span::styled(
hist_str,
Style::default().fg(self.theme.accent).bg(panel_bg),
));
}
let total_w = footer_spans.iter().map(|s| s.width() as u16).sum::<u16>();
if total_w < inner.width {
@@ -640,6 +661,28 @@ pub fn build_size_index_from_snap(
snaps.iter().map(|(n, s, _)| (n.clone(), *s)).collect()
}
/// Build a log2-bucketed file-size histogram (10 buckets covering
/// 1B..1TB). Skips directories. Returns 10 counts, one per
/// bucket (bucket 0 = 1B..1KiB, bucket 9 = ≥512GiB).
pub fn size_histogram(panel: &Panel) -> [u64; 10] {
let mut hist = [0u64; 10];
for entry in panel.entries() {
if entry.is_dir() || entry.name == ".." {
continue;
}
let size = entry.stat.size;
if size == 0 {
hist[0] += 1;
continue;
}
// log2(size) for non-zero sizes, clamped to [0, 63].
// Bucket 0 covers 0..1KiB, bucket 9 covers >=512GiB.
let bucket = (63 - size.leading_zeros() as usize).saturating_sub(9).min(9);
hist[bucket] += 1;
}
hist
}
/// Mark entries whose size differs from the index. Returns count marked.
pub fn mark_differing_from_snap(
panel: &mut Panel,
@@ -843,4 +886,31 @@ pub fn quickview_lines(panel: &Panel) -> Vec<String> {
return vec!["(empty file)".to_string()];
}
lines
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::filemanager::panel::Panel;
use std::fs;
#[test]
fn size_histogram_buckets_files_by_log2_size() {
let dir = std::env::temp_dir().join("tlc-fm-size-histogram");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
// bucket 0: 1B..1KiB
fs::write(dir.join("small.txt"), b"hi").unwrap();
// bucket 3: 8KiB..64KiB
let mut mid = Vec::with_capacity(16 * 1024);
mid.resize(16 * 1024, b'x');
fs::write(dir.join("mid.bin"), &mid).unwrap();
let panel = Panel::new(&dir, &Default::default()).unwrap();
let hist = size_histogram(&panel);
let total: u64 = hist.iter().sum();
assert_eq!(total, 2, "expected 2 regular files, got {hist:?}");
// 16 KiB = 2^14 → log2(14) - 9 = bucket 5.
assert!(hist[5] >= 1, "16KiB file should land in bucket 5, got {hist:?}");
assert!(hist[0] >= 1, "2B file should land in bucket 0");
let _ = fs::remove_dir_all(&dir);
}
}