From 193d2926d0749c847b7437b7dd8a8d4a0720e05a Mon Sep 17 00:00:00 2001 From: vasilito Date: Mon, 6 Jul 2026 00:46:05 +0300 Subject: [PATCH] =?UTF-8?q?tlc:=20visual=20polish=20V1-V6=20=E2=80=94=20fo?= =?UTF-8?q?cused=20borders,=20sparkline,=20size=20coloring,=20fractional?= =?UTF-8?q?=20gauge,=20color=20cache,=20disk-free?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 cache, null-separated key V6: Disk-free indicator in status bar — df subprocess, green/yellow/red thresholds 1381 tests pass (+12 new) --- .../source/src/filemanager/filehighlight.rs | 63 ++++++++++ .../tui/tlc/source/src/filemanager/render.rs | 22 ++-- .../tui/tlc/source/src/ops/progress.rs | 112 ++++++++++++++---- .../tui/tlc/source/src/terminal/color.rs | 20 ++++ .../tui/tlc/source/src/terminal/mc_skin.rs | 47 ++++++-- .../tui/tlc/source/src/terminal/status.rs | 85 ++++++++++++- .../tui/tlc/source/src/widget/gauge.rs | 81 ++++++++++++- 7 files changed, 390 insertions(+), 40 deletions(-) diff --git a/local/recipes/tui/tlc/source/src/filemanager/filehighlight.rs b/local/recipes/tui/tlc/source/src/filemanager/filehighlight.rs index b2d9c7a167..a24d80fd7c 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/filehighlight.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/filehighlight.rs @@ -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 { + 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"); + } + } } diff --git a/local/recipes/tui/tlc/source/src/filemanager/render.rs b/local/recipes/tui/tlc/source/src/filemanager/render.rs index 736a813df2..f15b4b583b 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/render.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/render.rs @@ -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(|| { diff --git a/local/recipes/tui/tlc/source/src/ops/progress.rs b/local/recipes/tui/tlc/source/src/ops/progress.rs index cb403f5af4..5283be5ed7 100644 --- a/local/recipes/tui/tlc/source/src/ops/progress.rs +++ b/local/recipes/tui/tlc/source/src/ops/progress.rs @@ -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, - /// When the dialog opened (for rate calculation). started: Option, - /// 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, + last_sample_at: Option, + 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] diff --git a/local/recipes/tui/tlc/source/src/terminal/color.rs b/local/recipes/tui/tlc/source/src/terminal/color.rs index e68fd8d910..43db21aa3c 100644 --- a/local/recipes/tui/tlc/source/src/terminal/color.rs +++ b/local/recipes/tui/tlc/source/src/terminal/color.rs @@ -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. /// diff --git a/local/recipes/tui/tlc/source/src/terminal/mc_skin.rs b/local/recipes/tui/tlc/source/src/terminal/mc_skin.rs index 097dd60f4c..600ad3c3d9 100644 --- a/local/recipes/tui/tlc/source/src/terminal/mc_skin.rs +++ b/local/recipes/tui/tlc/source/src/terminal/mc_skin.rs @@ -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 { } /// 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 { - 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>>> = + 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()); + } } diff --git a/local/recipes/tui/tlc/source/src/terminal/status.rs b/local/recipes/tui/tlc/source/src/terminal/status.rs index 0927fcd193..21cedd314f 100644 --- a/local/recipes/tui/tlc/source/src/terminal/status.rs +++ b/local/recipes/tui/tlc/source/src/terminal/status.rs @@ -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::().ok()?; + let total = cols[1].parse::().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, - /// 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, } 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) { + 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 = 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"); + } } diff --git a/local/recipes/tui/tlc/source/src/widget/gauge.rs b/local/recipes/tui/tlc/source/src/widget/gauge.rs index b0e1125987..5d77be4f63 100644 --- a/local/recipes/tui/tlc/source/src/widget/gauge.rs +++ b/local/recipes/tui/tlc/source/src/widget/gauge.rs @@ -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 = bar.chars().collect(); + let label_chars: Vec = label_text.chars().collect(); + let mut overlay: Vec = 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); + } }