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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user