use crate::render::palette::RgbColor;
use crate::render::theme::PanelStyle;
pub fn gauge(
value: f32,
range: (f32, f32),
default: f32,
width: usize,
st: &PanelStyle,
) -> Vec<(char, RgbColor)> {
if width == 0 {
return Vec::new();
}
let value_ratio = normalize(value, range);
let filled = ((value_ratio * width as f32).round() as usize).min(width);
let mut cells = vec![('░', st.muted); width];
cells[..filled].fill(('█', st.accent_active));
let value_tick = tick_column(value_ratio, width);
cells[value_tick] = ('│', st.accent_active);
let default_tick = tick_column(normalize(default, range), width);
cells[default_tick] = ('▲', st.state_default);
cells
}
pub fn sparkline(values: &[f32]) -> String {
const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let mut min = f32::INFINITY;
let mut max = f32::NEG_INFINITY;
for &value in values {
if value.is_finite() {
min = min.min(value);
max = max.max(value);
}
}
let range = (min, max);
let mut result = String::with_capacity(values.len().saturating_mul('█'.len_utf8()));
for &value in values {
let level = (normalize(value, range) * 7.0).round() as usize;
result.push(BARS[level]);
}
result
}
pub fn swatch(accent: RgbColor) -> Vec<(char, RgbColor)> {
vec![('■', accent)]
}
pub fn separator(st: &PanelStyle) -> (char, RgbColor) {
('─', st.muted)
}
pub fn legend(entries: &[(&str, RgbColor)]) -> Vec<(char, RgbColor)> {
let label_chars = entries
.iter()
.map(|(label, _)| label.chars().count())
.sum::<usize>();
let capacity = label_chars.saturating_add(entries.len().saturating_sub(1));
let mut cells = Vec::with_capacity(capacity);
let mut preceding_color = None;
for &(label, color) in entries {
if let Some(space_color) = preceding_color {
cells.push((' ', space_color));
}
cells.extend(label.chars().map(|ch| (ch, color)));
preceding_color = Some(color);
}
cells
}
pub fn truncate_ellipsis(s: &str, max: usize) -> String {
if max == 0 {
return String::new();
}
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max {
s.to_string()
} else {
let mut result: String = chars[..max - 1].iter().collect();
result.push('…');
result
}
}
fn normalize(value: f32, (lo, hi): (f32, f32)) -> f32 {
if !value.is_finite() || !lo.is_finite() || !hi.is_finite() || hi <= lo {
return 0.0;
}
let value = f64::from(value);
let lo = f64::from(lo);
let hi = f64::from(hi);
((value - lo) / (hi - lo)).clamp(0.0, 1.0) as f32
}
fn tick_column(ratio: f32, width: usize) -> usize {
((ratio * width.saturating_sub(1) as f32).round() as usize).min(width - 1)
}
#[cfg(test)]
mod kit_tests {
use super::*;
use crate::render::theme::GRUVBOX_DARK;
#[test]
fn gauge_fills_proportionally_and_marks_ticks() {
let g = gauge(5.0, (0.0, 10.0), 0.0, 4, &GRUVBOX_DARK);
assert_eq!(g.len(), 4);
assert_eq!(
g,
vec![
('▲', GRUVBOX_DARK.state_default),
('█', GRUVBOX_DARK.accent_active),
('│', GRUVBOX_DARK.accent_active),
('░', GRUVBOX_DARK.muted),
]
);
}
#[test]
fn zero_width_gauge_is_empty() {
assert!(gauge(5.0, (0.0, 10.0), 0.0, 0, &GRUVBOX_DARK).is_empty());
}
#[test]
fn gauge_places_ticks_at_endpoints_and_default_wins_collisions() {
assert_eq!(
gauge(0.0, (0.0, 1.0), 1.0, 3, &GRUVBOX_DARK),
vec![
('│', GRUVBOX_DARK.accent_active),
('░', GRUVBOX_DARK.muted),
('▲', GRUVBOX_DARK.state_default),
]
);
assert_eq!(
gauge(1.0, (0.0, 1.0), 1.0, 1, &GRUVBOX_DARK),
vec![('▲', GRUVBOX_DARK.state_default)]
);
}
#[test]
fn gauge_collapses_invalid_ranges_and_nonfinite_values_safely() {
let collapsed = vec![
('▲', GRUVBOX_DARK.state_default),
('░', GRUVBOX_DARK.muted),
('░', GRUVBOX_DARK.muted),
];
assert_eq!(gauge(1.0, (1.0, 1.0), 1.0, 3, &GRUVBOX_DARK), collapsed);
assert_eq!(
gauge(f32::NAN, (0.0, 1.0), f32::INFINITY, 3, &GRUVBOX_DARK),
collapsed
);
assert_eq!(gauge(0.5, (1.0, 0.0), 0.5, 3, &GRUVBOX_DARK), collapsed);
}
#[test]
fn normalization_preserves_extreme_finite_range() {
let range = (-f32::MAX, f32::MAX);
assert_eq!(normalize(-f32::MAX, range), 0.0);
assert!((normalize(0.0, range) - 0.5).abs() <= f32::EPSILON);
assert_eq!(normalize(f32::MAX, range), 1.0);
}
#[test]
fn extreme_finite_ranges_reach_midpoint_and_upper_levels() {
let range = (-f32::MAX, f32::MAX);
let upper: String = gauge(f32::MAX, range, -f32::MAX, 5, &GRUVBOX_DARK)
.iter()
.map(|(ch, _)| ch)
.collect();
let middle: String = gauge(0.0, range, -f32::MAX, 5, &GRUVBOX_DARK)
.iter()
.map(|(ch, _)| ch)
.collect();
assert_eq!(upper, "▲███│");
assert_eq!(middle, "▲█│░░");
assert_eq!(sparkline(&[-f32::MAX, 0.0, f32::MAX]), "▁▅█");
}
#[test]
fn sparkline_maps_min_and_max() {
let s = sparkline(&[0.0, 1.0]);
let cs: Vec<char> = s.chars().collect();
assert_eq!(cs[0], '▁');
assert_eq!(cs[1], '█');
}
#[test]
fn empty_sparkline_is_empty() {
assert!(sparkline(&[]).is_empty());
}
#[test]
fn constant_and_all_invalid_sparklines_use_the_lowest_level() {
assert_eq!(sparkline(&[3.0, 3.0]), "▁▁");
assert_eq!(
sparkline(&[f32::NAN, f32::INFINITY, f32::NEG_INFINITY]),
"▁▁▁"
);
}
#[test]
fn swatch_is_block_in_accent() {
let accent = RgbColor::new(7, 8, 9);
assert_eq!(swatch(accent), vec![('■', accent)]);
}
#[test]
fn separator_is_one_muted_divider() {
assert_eq!(separator(&GRUVBOX_DARK), ('─', GRUVBOX_DARK.muted));
}
#[test]
fn truncate_adds_ellipsis_only_when_cut() {
assert_eq!(truncate_ellipsis("hello", 10), "hello");
assert_eq!(truncate_ellipsis("hello world", 8), "hello w…");
}
#[test]
fn truncate_ellipsis_zero_max_is_empty() {
assert_eq!(truncate_ellipsis("hello", 0), "");
}
#[test]
fn truncate_ellipsis_exact_fit_unchanged() {
assert_eq!(truncate_ellipsis("hello", 5), "hello");
}
#[test]
fn legend_separates_colored_labels_with_one_preceding_color_space() {
let cli = RgbColor::new(1, 2, 3);
let modified = RgbColor::new(4, 5, 6);
assert_eq!(
legend(&[("C", cli), ("M", modified)]),
vec![('C', cli), (' ', cli), ('M', modified)]
);
}
}