use std::ffi::CStr;
pub struct Symbols {
pub ellipsis: &'static str,
pub bar_filled: &'static str,
pub bar_head: &'static str,
pub bar_empty: &'static str,
pub hourglass: &'static str,
pub play: &'static str,
pub check: &'static str,
pub cross: &'static str,
pub arrow: &'static str,
}
pub const UNICODE_SYMBOLS: Symbols = Symbols {
ellipsis: "\u{2026}",
bar_filled: "\u{2501}",
bar_head: "\u{2578}",
bar_empty: "\u{2500}",
hourglass: "\u{23f3}",
play: "\u{25b6}",
check: "\u{2713}",
cross: "\u{2717}",
arrow: "\u{2192}",
};
pub const ASCII_SYMBOLS: Symbols = Symbols {
ellipsis: "..",
bar_filled: "=",
bar_head: ">",
bar_empty: "-",
hourglass: "~",
play: ">",
check: "+",
cross: "x",
arrow: "->",
};
pub fn detect_symbols() -> &'static Symbols {
unsafe {
libc::setlocale(libc::LC_ALL, c"".as_ptr());
let codeset = libc::nl_langinfo(libc::CODESET);
if !codeset.is_null() {
let cs = CStr::from_ptr(codeset).to_string_lossy().to_lowercase();
if cs.contains("utf-8") || cs.contains("utf8") {
return &UNICODE_SYMBOLS;
}
}
}
&ASCII_SYMBOLS
}
pub fn terminal_cols() -> usize {
unsafe {
let mut ws: libc::winsize = std::mem::zeroed();
if libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 {
ws.ws_col as usize
} else {
80
}
}
}
pub const LINE_FORMAT_OVERHEAD: usize = 44;
pub fn max_name_for_cols(cols: usize) -> usize {
cols.saturating_sub(LINE_FORMAT_OVERHEAD).max(20)
}
pub fn truncate_name(name: &str, max_len: usize, sym: &Symbols) -> String {
if name.chars().count() <= max_len {
return name.to_string();
}
let ellipsis_len = sym.ellipsis.chars().count();
if max_len <= ellipsis_len {
return name.chars().take(max_len).collect();
}
let keep = max_len - ellipsis_len;
let truncated: String = name.chars().take(keep).collect();
format!("{truncated}{}", sym.ellipsis)
}
pub fn progress_bar_str(fraction: f64, width: usize, sym: &Symbols) -> String {
let filled = (fraction * width as f64) as usize;
if filled >= width {
sym.bar_filled.repeat(width)
} else {
format!(
"{}{}{}",
sym.bar_filled.repeat(filled),
sym.bar_head,
sym.bar_empty.repeat(width.saturating_sub(filled + 1))
)
}
}
pub fn embedded_config_banner(use_unicode: bool) -> String {
const W: usize = 62;
let content: &[&str] = &[
" hvac: using built-in default configuration",
"",
" To customise encoding settings, save the defaults to a file:",
"",
" hvac --dump-config > config.yaml",
" $EDITOR config.yaml",
" hvac --config config.yaml /path/to/media",
"",
" Suppress this message: hvac --quiet ...",
];
let (tl, tr, bl, br, h, v) = if use_unicode {
("╭", "╮", "╰", "╯", "─", "│")
} else {
("+", "+", "+", "+", "-", "|")
};
let top = format!("{}{}{}", tl, h.repeat(W), tr);
let bot = format!("{}{}{}", bl, h.repeat(W), br);
let mut out = top;
for line in content {
let pad = W.saturating_sub(line.chars().count());
out.push('\n');
out.push_str(&format!("{}{}{}{}", v, line, " ".repeat(pad), v));
}
out.push('\n');
out.push_str(&bot);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_name_short_unchanged() {
assert_eq!(truncate_name("file.mkv", 30, &ASCII_SYMBOLS), "file.mkv");
}
#[test]
fn truncate_name_truncates_with_ellipsis() {
let name = "a".repeat(40);
let out = truncate_name(&name, 10, &ASCII_SYMBOLS);
assert_eq!(out, "aaaaaaaa..");
assert_eq!(out.chars().count(), 10);
}
#[test]
fn truncate_name_unicode_ellipsis_fits_exactly() {
let name = "a".repeat(40);
let out = truncate_name(&name, 10, &UNICODE_SYMBOLS);
assert_eq!(out.chars().count(), 10);
assert!(out.ends_with('\u{2026}'));
}
#[test]
fn truncate_name_zero_max_len_does_not_panic() {
let out = truncate_name("anything", 0, &ASCII_SYMBOLS);
assert_eq!(out, "");
}
#[test]
fn truncate_name_small_max_len_clips_below_ellipsis() {
let out = truncate_name("abcdef", 1, &ASCII_SYMBOLS);
assert_eq!(out.chars().count(), 1);
assert_eq!(out, "a");
}
#[test]
fn truncate_name_unicode_chars_count() {
let name = "あいうえおかきくけこ";
assert_eq!(name.chars().count(), 10);
let out = truncate_name(name, 10, &UNICODE_SYMBOLS);
assert_eq!(out, name);
}
#[test]
fn progress_bar_zero_has_head_only() {
let s = progress_bar_str(0.0, 10, &ASCII_SYMBOLS);
assert_eq!(s.chars().count(), 10);
assert!(s.starts_with('>'), "head should lead: {s}");
}
#[test]
fn progress_bar_full_at_one() {
let s = progress_bar_str(1.0, 10, &ASCII_SYMBOLS);
assert_eq!(s, "==========");
}
#[test]
fn progress_bar_half() {
let s = progress_bar_str(0.5, 10, &ASCII_SYMBOLS);
assert_eq!(s.chars().count(), 10);
assert!(s.starts_with("====="));
}
#[test]
fn progress_bar_clamps_above_one() {
let s = progress_bar_str(2.5, 10, &ASCII_SYMBOLS);
assert_eq!(s, "==========");
}
#[test]
fn max_name_for_narrow_terminal_floors_at_20() {
assert_eq!(max_name_for_cols(30), 20);
assert_eq!(max_name_for_cols(0), 20);
}
#[test]
fn max_name_for_normal_terminal() {
assert_eq!(max_name_for_cols(80), 36);
assert_eq!(max_name_for_cols(120), 76);
}
#[test]
fn banner_unicode_form_uses_box_drawing() {
let b = embedded_config_banner(true);
assert!(b.contains("hvac: using built-in default configuration"));
assert!(b.contains("╭"));
assert!(b.contains("╯"));
}
#[test]
fn banner_ascii_form_uses_plain_borders() {
let b = embedded_config_banner(false);
assert!(b.contains("hvac: using built-in default configuration"));
assert!(b.starts_with("+"));
assert!(b.ends_with("+"));
assert!(!b.contains("╭"));
}
}