tlc: visual polish V1-V6 — focused borders, sparkline, size coloring, fractional gauge, color cache, disk-free

V1: Focused panel border — active uses theme.accent+BOLD, inactive uses darken(border,30)
V2: Transfer-rate sparkline in ProgressDialog — ring buffer, 0.5s throttle, block glyphs
V3: Dynamic file-size coloring — warmth gradient by size (GB+20, 100MB+12, 10MB+6, 1MB+3, <512B dim)
V4: Sub-cell fractional progress bars — 8x resolution via ▏▎▍▌▋▊▉█ glyphs
V5: Pre-resolve MC skin color pairs — global Mutex<HashMap> cache, null-separated key
V6: Disk-free indicator in status bar — df subprocess, green/yellow/red thresholds

1381 tests pass (+12 new)
This commit is contained in:
2026-07-06 00:46:05 +03:00
parent e07f264ab7
commit 193d2926d0
7 changed files with 390 additions and 40 deletions
@@ -165,6 +165,37 @@ pub fn file_type_color(
}
}
/// Apply a subtle magnitude-based tint to a base color, conveying
/// file size at a glance. Larger files get progressively warmer
/// (more red), tiny files get slightly dimmer (darker).
///
/// Returns `None` when the color is not RGB (Indexed/Reset pass
/// through unchanged — the caller should use the original color).
#[must_use]
pub fn size_tint(base: ratatui::style::Color, size: u64) -> Option<ratatui::style::Color> {
let (r, g, b) = match base {
ratatui::style::Color::Rgb(r, g, b) => (r, g, b),
_ => return None,
};
let warmth = if size >= 1_073_741_824 {
20u8
} else if size >= 104_857_600 {
12
} else if size >= 10_485_760 {
6
} else if size >= 1_048_576 {
3
} else {
0
};
let dim = if size < 512 { 8u8 } else { 0 };
Some(ratatui::style::Color::Rgb(
r.saturating_add(warmth).saturating_sub(dim),
g.saturating_sub(warmth / 2).saturating_sub(dim),
b.saturating_sub(warmth).saturating_sub(dim),
))
}
/// Extract the lowercased extension from `filename`, if any.
///
/// Returns `None` when the name has no dot or when the only dot is the
@@ -303,4 +334,36 @@ mod tests {
assert!(file_type_color(FileType::Documentation, &theme).is_some());
assert!(file_type_color(FileType::Database, &theme).is_some());
}
#[test]
fn size_tint_returns_none_for_non_rgb() {
assert_eq!(size_tint(ratatui::style::Color::Indexed(3), 9999), None);
assert_eq!(size_tint(ratatui::style::Color::Reset, 9999), None);
}
#[test]
fn size_tint_small_file_dims() {
let base = ratatui::style::Color::Rgb(200, 200, 200);
let tinted = size_tint(base, 100).unwrap();
if let ratatui::style::Color::Rgb(r, g, b) = tinted {
assert!(r < 200, "small file R should be dimmed");
assert!(g < 200);
assert!(b < 200);
} else {
panic!("expected Rgb");
}
}
#[test]
fn size_tint_large_file_warms() {
let base = ratatui::style::Color::Rgb(100, 150, 200);
let tinted = size_tint(base, 2_000_000_000).unwrap();
if let ratatui::style::Color::Rgb(r, g, b) = tinted {
assert!(r >= 100, "large file R should not decrease");
assert!(g <= 150, "large file G should decrease");
assert!(b < 200, "large file B should decrease");
} else {
panic!("expected Rgb");
}
}
}
@@ -43,6 +43,11 @@ impl FileManager {
} else {
String::new()
});
let active_panel_path = match self.active {
Active::Left => self.left.path().to_path_buf(),
Active::Right => self.right.path().to_path_buf(),
};
self.status.set_disk_path(active_panel_path);
if !self.panels_visible {
// When the active status message contains newlines,
@@ -285,17 +290,19 @@ impl FileManager {
.unwrap_or(self.theme.title_bg);
let disabled_fg = core_disabled.map(|p| p.fg).unwrap_or(self.theme.hidden);
let title = format!(" {} ", format_utils::path_short(panel.path()));
let active_border_color = if self.panel_switch_anim < 100 {
self.theme.info
} else {
self.theme.accent
};
let inactive_border_color = crate::terminal::color::darken(self.theme.border, 30);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(if active {
if self.panel_switch_anim < 100 {
Style::default().fg(self.theme.info).bg(panel_bg).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(header_fg).bg(panel_bg)
}
Style::default().fg(active_border_color).bg(panel_bg).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.theme.border).bg(panel_bg)
Style::default().fg(inactive_border_color).bg(panel_bg)
})
.style(Style::default().bg(panel_bg).fg(panel_fg))
.title(Span::styled(
@@ -474,7 +481,8 @@ pub fn entry_style(
let ft = filehighlight::categorize(&e.name, is_executable);
let color = filehighlight::file_type_color(ft, theme)
.unwrap_or_else(|| core_default.map(|p| p.fg).unwrap_or(theme.foreground));
Style::default().fg(color)
let tinted = filehighlight::size_tint(color, e.stat.size).unwrap_or(color);
Style::default().fg(tinted)
};
if cursor && marked {
let pair = core_markselect.unwrap_or_else(|| {
@@ -19,20 +19,22 @@ use crate::widget::ProgressGauge;
/// The progress dialog. Renders a running operation with a
/// centered modal overlay and a Cancel button.
pub struct ProgressDialog {
/// Title shown in the dialog border.
pub title: String,
/// The handle to the running operation (if any).
pub handle: Option<OpHandle>,
/// When the dialog opened (for rate calculation).
started: Option<Instant>,
/// Width as a fraction of the parent area.
pub width_pct: f32,
/// Height as a fraction of the parent area.
pub height_pct: f32,
/// Whether the Cancel button is currently focused.
pub cancel_focused: bool,
rate_samples: Vec<u64>,
last_sample_at: Option<Instant>,
last_bytes_done: u64,
}
const SPARKLINE_MAX: usize = 40;
/// Data-to-glyph mapping: index 0 → `▁`, 7 → `█`. Eight levels per cell.
const SPARK_GLYPHS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
impl ProgressDialog {
/// Create a new progress dialog with no active operation.
#[must_use]
@@ -44,6 +46,9 @@ impl ProgressDialog {
width_pct: 0.7,
height_pct: 0.4,
cancel_focused: true,
rate_samples: Vec::new(),
last_sample_at: None,
last_bytes_done: 0,
}
}
@@ -51,28 +56,71 @@ impl ProgressDialog {
pub fn set_handle(&mut self, h: OpHandle) {
self.handle = Some(h);
self.started = Some(Instant::now());
self.rate_samples.clear();
self.last_sample_at = None;
self.last_bytes_done = 0;
}
fn record_rate_sample(&mut self, bytes_done: u64) {
let now = Instant::now();
let prev_time = match self.last_sample_at {
Some(t) => t,
None => {
self.last_sample_at = Some(now);
self.last_bytes_done = bytes_done;
return;
}
};
let elapsed = now.duration_since(prev_time).as_secs_f64();
if elapsed < 0.5 {
return;
}
let delta = bytes_done.saturating_sub(self.last_bytes_done);
let rate = if elapsed > 0.0 { (delta as f64 / elapsed) as u64 } else { 0 };
if self.rate_samples.len() >= SPARKLINE_MAX {
self.rate_samples.remove(0);
}
self.rate_samples.push(rate);
self.last_sample_at = Some(now);
self.last_bytes_done = bytes_done;
}
#[must_use]
fn render_sparkline(&self, width: usize) -> String {
if self.rate_samples.is_empty() || width == 0 {
return String::new();
}
let max_rate = self.rate_samples.iter().copied().max().unwrap_or(1).max(1);
let visible = &self.rate_samples[self.rate_samples.len().saturating_sub(width)..];
let mut out = String::with_capacity(visible.len());
for &rate in visible {
let level = ((rate as f64 / max_rate as f64) * 7.0).round() as usize;
let level = level.min(7);
out.push(SPARK_GLYPHS[level]);
}
out
}
/// Render the dialog into a frame.
///
/// `theme` supplies the title, current file, gauge, and cancel
/// button colours so the progress dialog follows the active skin.
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let popup = centered_percent_rect(area, self.width_pct, self.height_pct);
let inner = render_popup(frame, popup, self.title.as_str(), theme);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // current file + path
Constraint::Length(1), // gauge
Constraint::Length(2), // stats (bytes / files / rate / eta)
Constraint::Min(1), // spacer
Constraint::Length(1), // cancel button
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(2),
Constraint::Min(1),
Constraint::Length(1),
])
.split(inner);
// 1. Current file.
let current = self
.handle
.as_ref()
@@ -84,7 +132,6 @@ impl ProgressDialog {
.wrap(Wrap { trim: true });
frame.render_widget(cur_p, chunks[0]);
// 2. Gauge.
let (bytes_done, bytes_total) = self
.handle
.as_ref()
@@ -93,21 +140,28 @@ impl ProgressDialog {
(p.bytes_done, p.bytes_total)
})
.unwrap_or((0, 1));
self.record_rate_sample(bytes_done);
let gauge = ProgressGauge::new()
.value(bytes_done)
.max(bytes_total.max(1))
.fg(theme.executable)
.bg(theme.hidden);
gauge.render(frame, chunks[1], theme);
gauge.render_fractional(frame, chunks[1], theme);
let spark_w = chunks[2].width as usize;
let spark = self.render_sparkline(spark_w);
if !spark.is_empty() {
let spark_p = Paragraph::new(Span::styled(
spark,
Style::default().fg(theme.info),
));
frame.render_widget(spark_p, chunks[2]);
}
// 3. Stats line.
let stats = self.format_stats();
let stats_p = Paragraph::new(stats);
frame.render_widget(stats_p, chunks[2]);
frame.render_widget(stats_p, chunks[3]);
// 4. Spacer (chunks[3] is empty).
// 5. Cancel button.
let btn_style = if self.cancel_focused {
Style::default()
.fg(theme.cursor_fg)
@@ -117,7 +171,7 @@ impl ProgressDialog {
Style::default().fg(theme.error)
};
let btn = Paragraph::new(Line::from(Span::styled(" [ Cancel] ", btn_style)));
frame.render_widget(btn, chunks[4]);
frame.render_widget(btn, chunks[5]);
}
fn format_stats(&self) -> String {
@@ -207,6 +261,22 @@ mod tests {
fn progress_dialog_starts_empty() {
let d = ProgressDialog::new();
assert!(d.handle.is_none());
assert!(d.rate_samples.is_empty());
}
#[test]
fn sparkline_empty_returns_empty() {
let d = ProgressDialog::new();
assert_eq!(d.render_sparkline(20), "");
}
#[test]
fn sparkline_renders_glyphs() {
let mut d = ProgressDialog::new();
d.rate_samples = vec![10, 50, 100, 200, 500];
let spark = d.render_sparkline(5);
assert_eq!(spark.chars().count(), 5);
assert!(spark.contains('█'), "highest sample should map to █, got: {spark}");
}
#[test]
@@ -150,6 +150,26 @@ pub struct Theme {
pub shadow: Color,
}
/// Darken a [`Color::Rgb`] value by reducing each channel by `amt`.
/// Non-Rgb colors are returned unchanged so themes built from
/// Indexed/Named colors degrade gracefully.
#[must_use]
pub fn darken(color: Color, amt: u8) -> Color {
match color {
Color::Rgb(r, g, b) => Color::Rgb(r.saturating_sub(amt), g.saturating_sub(amt), b.saturating_sub(amt)),
other => other,
}
}
/// Lighten a [`Color::Rgb`] value by increasing each channel by `amt`.
#[must_use]
pub fn lighten(color: Color, amt: u8) -> Color {
match color {
Color::Rgb(r, g, b) => Color::Rgb(r.saturating_add(amt), g.saturating_add(amt), b.saturating_add(amt)),
other => other,
}
}
impl Theme {
/// Background colour for the editor's current-line highlight.
///
@@ -5,6 +5,7 @@
//! into TLC's smaller [`Theme`] surface.
use std::collections::{HashMap, HashSet};
use std::sync::{LazyLock, Mutex};
use ratatui::style::Color;
@@ -107,16 +108,35 @@ pub fn theme_by_name(name: &str) -> Option<Theme> {
}
/// Resolve a concrete MC section/key slot into a foreground/background pair.
///
/// Results are cached in a global `HashMap` keyed by `(skin_name, section, key)`
/// so repeated calls during the render loop (which fire this function dozens of
/// times per frame) return in O(1) after the first lookup.
pub fn color_pair(name: &str, section: &str, key: &str) -> Option<ColorPair> {
let source = BUILTIN_MC_SKINS.iter().find(|skin| skin.name == name)?;
let parsed = parse_mc_skin(source.data).ok()?;
let style = resolve_style(&parsed, section, key);
Some(ColorPair {
fg: style.fg?,
bg: style.bg?,
})
let cache_key = format!("{name}\0{section}\0{key}");
{
let guard = COLOR_PAIR_CACHE.lock().unwrap_or_else(|p| p.into_inner());
if let Some(cached) = guard.get(&cache_key) {
return *cached;
}
}
let result = {
let source = BUILTIN_MC_SKINS.iter().find(|skin| skin.name == name)?;
let parsed = parse_mc_skin(source.data).ok()?;
let style = resolve_style(&parsed, section, key);
Some(ColorPair {
fg: style.fg?,
bg: style.bg?,
})
};
let mut guard = COLOR_PAIR_CACHE.lock().unwrap_or_else(|p| p.into_inner());
guard.insert(cache_key, result);
result
}
static COLOR_PAIR_CACHE: LazyLock<Mutex<HashMap<String, Option<ColorPair>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn parse_theme(name: &'static str, text: &str) -> Theme {
let parsed = parse_mc_skin(text).unwrap_or_default();
let core_default = resolve_style(&parsed, "core", "_default_");
@@ -441,4 +461,17 @@ mod tests {
assert_eq!(entries.get("nicedark").unwrap(), "Nice and Dark");
assert!(entries.contains_key("sand256"));
}
#[test]
fn color_pair_returns_same_result_on_repeat() {
let a = color_pair("julia256", "core", "_default_");
let b = color_pair("julia256", "core", "_default_");
assert_eq!(a, b);
}
#[test]
fn color_pair_returns_none_for_missing_slot() {
let result = color_pair("julia256", "nonexistent_section", "missing_key");
assert!(result.is_none());
}
}
@@ -4,6 +4,7 @@
//! feedback (e.g., "3 files copied", "Permission denied") plus a hint
//! area for the active command.
use std::path::PathBuf;
use std::time::{Duration, Instant};
use ratatui::layout::Rect;
@@ -15,6 +16,34 @@ use ratatui::Frame;
use super::color::Theme;
use super::mc_skin;
/// Query filesystem free/total space via `df -B1` subprocess.
/// Returns `None` when `df` is unavailable or fails.
#[must_use]
pub fn disk_free(path: &std::path::Path) -> Option<(u64, u64)> {
let output = std::process::Command::new("df")
.arg("-B1")
.arg("--output=avail,size")
.arg(path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut lines = stdout.lines().skip(1);
let data_line = lines.next()?;
let cols: Vec<&str> = data_line.split_whitespace().collect();
if cols.len() < 2 {
return None;
}
let free = cols[0].parse::<u64>().ok()?;
let total = cols[1].parse::<u64>().ok()?;
if total == 0 {
return None;
}
Some((free, total))
}
/// A short message to be shown in the status line.
#[derive(Debug, Clone)]
pub struct Message {
@@ -31,8 +60,8 @@ impl Message {
text: text.into(),
posted: Instant::now(),
ttl,
}
}
}
/// True if this message has expired.
pub fn expired(&self) -> bool {
@@ -61,12 +90,10 @@ impl Message {
#[derive(Debug, Clone)]
#[derive(Default)]
pub struct StatusLine {
/// Current short message (if any).
message: Option<Message>,
/// Hint text for the active command (F-key tooltip).
hint: String,
/// Spinner character shown at the right edge when jobs are active.
spinner_char: String,
disk_path: Option<PathBuf>,
}
impl StatusLine {
@@ -105,6 +132,11 @@ impl StatusLine {
self.spinner_char = ch;
}
/// Set the filesystem path used for the disk-free indicator.
pub fn set_disk_path(&mut self, path: impl Into<PathBuf>) {
self.disk_path = Some(path.into());
}
/// True if the current message contains newlines (needs more
/// than one status row). Use to decide whether to allocate a
/// taller area for the status line at render time.
@@ -185,6 +217,23 @@ impl StatusLine {
));
let mut right_spans: Vec<Span> = Vec::new();
if let Some(ref disk_path) = self.disk_path {
if let Some((free, total)) = disk_free(disk_path) {
let pct_free = ((free as f64 / total as f64) * 100.0) as u8;
let disk_color = if pct_free > 20 {
theme.executable
} else if pct_free > 10 {
theme.warning
} else {
theme.error
};
let free_str = format_size_short(free);
right_spans.push(Span::styled(
format!(" \u{25cf}{free_str} "),
Style::default().fg(disk_color).bg(status_bg),
));
}
}
if !self.spinner_char.is_empty() {
right_spans.push(Span::styled(
self.spinner_char.clone(),
@@ -215,6 +264,19 @@ impl StatusLine {
}
}
#[must_use]
fn format_size_short(n: u64) -> String {
if n >= 1_073_741_824 {
format!("{:.0}G", n as f64 / 1_073_741_824.0)
} else if n >= 1_048_576 {
format!("{:.0}M", n as f64 / 1_048_576.0)
} else if n >= 1024 {
format!("{:.0}K", n as f64 / 1024.0)
} else {
format!("{n}B")
}
}
#[cfg(test)]
mod tests {
@@ -278,4 +340,19 @@ mod tests {
let m = crate::terminal::status::Message::new("a\nb\nc\nd", std::time::Duration::from_secs(1));
assert_eq!(m.line_count(), 4);
}
#[test]
fn format_size_short_units() {
assert_eq!(format_size_short(0), "0B");
assert_eq!(format_size_short(512), "512B");
assert_eq!(format_size_short(1024), "1K");
assert_eq!(format_size_short(2_000_000), "2M");
assert_eq!(format_size_short(3_000_000_000), "3G");
}
#[test]
fn disk_free_returns_some_for_root() {
let result = disk_free(std::path::Path::new("/"));
assert!(result.is_some(), "statvfs(/) should succeed on Linux");
}
}
@@ -7,7 +7,7 @@
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Gauge, LineGauge};
use ratatui::widgets::{Gauge, LineGauge, Paragraph};
use ratatui::Frame;
use crate::terminal::color::Theme;
@@ -112,6 +112,52 @@ impl ProgressGauge {
frame.render_widget(gauge, area);
}
/// Render a sub-cell progress bar using Unicode fractional blocks.
///
/// Each cell represents 1/8 of a character width via the glyphs
/// `▏▎▍▌▋▊▉█`, giving 8× horizontal resolution over a stock `Gauge`.
/// The label is centered on top of the bar.
pub fn render_fractional(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let width = area.width as usize;
if width == 0 {
return;
}
let ratio = self.ratio();
let total_eighths = (width * 8) as f64;
let filled_eighths = (ratio * total_eighths).round() as usize;
let full_blocks = filled_eighths / 8;
let remainder = filled_eighths % 8;
let empty_blocks = width.saturating_sub(full_blocks + (if remainder > 0 { 1 } else { 0 }));
const FRACTION_GLYPHS: &[&str] = &["", "", "", "", "", "", "", "", ""];
let mut bar = String::with_capacity(width * 4);
bar.push_str(&"".repeat(full_blocks));
if remainder > 0 {
bar.push_str(FRACTION_GLYPHS[remainder]);
}
bar.push_str(&"".repeat(empty_blocks));
let label_text = self.label.unwrap_or("");
let label_len = label_text.chars().count();
if label_len > 0 && label_len < width {
let offset = (width - label_len) / 2;
let bar_bytes: Vec<char> = bar.chars().collect();
let label_chars: Vec<char> = label_text.chars().collect();
let mut overlay: Vec<char> = bar_bytes;
for (i, &ch) in label_chars.iter().enumerate() {
if offset + i < overlay.len() {
overlay[offset + i] = ch;
}
}
bar = overlay.iter().collect();
}
let p = Paragraph::new(bar)
.style(Style::default().fg(self.fg).bg(self.bg));
frame.render_widget(p, area);
}
/// Render a thin (single-character) line gauge.
///
/// `theme` is currently unused but kept on the signature so every
@@ -176,4 +222,37 @@ mod tests {
assert_eq!(g.max, 50);
assert_eq!(g.label, Some("50%"));
}
#[test]
fn fractional_bar_math_full() {
let g = ProgressGauge::new().value(100).max(100);
let width = 10usize;
let total_eighths = (width * 8) as f64;
let filled = (g.ratio() * total_eighths).round() as usize;
assert_eq!(filled, 80);
assert_eq!(filled / 8, 10);
assert_eq!(filled % 8, 0);
}
#[test]
fn fractional_bar_math_half() {
let g = ProgressGauge::new().value(50).max(100);
let width = 10usize;
let total_eighths = (width * 8) as f64;
let filled = (g.ratio() * total_eighths).round() as usize;
assert_eq!(filled, 40);
assert_eq!(filled / 8, 5);
assert_eq!(filled % 8, 0);
}
#[test]
fn fractional_bar_math_quarter() {
let g = ProgressGauge::new().value(25).max(100);
let width = 10usize;
let total_eighths = (width * 8) as f64;
let filled = (g.ratio() * total_eighths).round() as usize;
assert_eq!(filled, 20);
assert_eq!(filled / 8, 2);
assert_eq!(filled % 8, 4);
}
}