use crate::constants::*;
use ratatui::style::Color;
use std::ffi::{OsStr, OsString};
use std::io::{Write, stdout};
use std::path::Path;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub fn display_width(text: &str) -> usize {
UnicodeWidthStr::width(text)
}
pub fn format_size(bytes: u64) -> String {
let (step, units) = if crate::display::decimal_sizes() { (1000.0, DECIMAL_UNITS) } else { (1024.0, UNITS) };
let mut size = bytes as f64;
let mut unit_index = 0;
while size.round() >= step && unit_index < units.len() - 1 {
size /= step;
unit_index += 1;
}
format!("{:.0} {}", size, units[unit_index])
}
pub fn format_modified(time: std::time::SystemTime, format: crate::options::DateFormat, now: std::time::SystemTime) -> String {
use crate::options::DateFormat;
let local: chrono::DateTime<chrono::Local> = time.into();
match format {
DateFormat::Short => local.format("%d/%m/%y %H:%M").to_string(),
DateFormat::Iso => local.format("%Y-%m-%d %H:%M").to_string(),
DateFormat::Relative => {
let seconds = now.duration_since(time).map_or(0, |age| age.as_secs());
match seconds {
0..60 => "just now".to_string(),
60..3600 => format!("{} min ago", seconds / 60),
3600..86_400 => format!("{} h ago", seconds / 3600),
86_400..2_592_000 => format!("{} days ago", seconds / 86_400),
2_592_000..31_536_000 => format!("{} mo ago", seconds / 2_592_000),
_ => format!("{} yr ago", seconds / 31_536_000),
}
}
}
}
pub fn format_exact(time: std::time::SystemTime, format: crate::options::DateFormat) -> String {
use crate::options::DateFormat;
let local: chrono::DateTime<chrono::Local> = time.into();
match format {
DateFormat::Short | DateFormat::Relative => local.format("%d/%m/%y %H:%M:%S").to_string(),
DateFormat::Iso => local.format("%Y-%m-%d %H:%M:%S").to_string(),
}
}
pub fn grouped(value: u64) -> String {
let digits = value.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (index, digit) in digits.chars().enumerate() {
if index > 0 && (digits.len() - index).is_multiple_of(3) {
out.push(' ');
}
out.push(digit);
}
out
}
pub fn substitute(word: &str, value: &OsStr) -> OsString {
let mut pieces = word.split("{}");
let mut out = OsString::from(pieces.next().unwrap_or_default());
for piece in pieces {
out.push(value);
out.push(piece);
}
out
}
pub fn set_system_clipboard(text: &str) {
if text.len() > OSC52_MAX_BYTES {
return;
}
let mut out = stdout();
let _ = write!(out, "\x1b]52;c;{}\x07", base64(text.as_bytes()));
let _ = out.flush();
}
fn base64(input: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut encoded = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let bytes = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
let packed = (u32::from(bytes[0]) << 16) | (u32::from(bytes[1]) << 8) | u32::from(bytes[2]);
encoded.push(ALPHABET[(packed >> 18) as usize & 63] as char);
encoded.push(ALPHABET[(packed >> 12) as usize & 63] as char);
encoded.push(if chunk.len() > 1 { ALPHABET[(packed >> 6) as usize & 63] as char } else { '=' });
encoded.push(if chunk.len() > 2 { ALPHABET[packed as usize & 63] as char } else { '=' });
}
encoded
}
#[cfg(test)]
mod base64_tests {
use super::base64;
#[test]
fn matches_known_vectors() {
assert_eq!(base64(b""), "");
assert_eq!(base64(b"f"), "Zg==");
assert_eq!(base64(b"fo"), "Zm8=");
assert_eq!(base64(b"foo"), "Zm9v");
assert_eq!(base64(b"foob"), "Zm9vYg==");
assert_eq!(base64(b"fooba"), "Zm9vYmE=");
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
assert_eq!(base64("héllo\n".as_bytes()), "aMOpbGxvCg==");
}
}
pub fn printable_line(line: &str) -> String {
let mut text = String::with_capacity(line.len());
for character in line.chars() {
if character == '\t' {
text.extend(std::iter::repeat_n(' ', crate::display::tab_width()));
} else if character.is_control() {
text.push('.');
} else {
text.push(character);
}
}
text
}
pub fn char_display_width(character: char) -> usize {
if character == '\t' {
crate::display::tab_width()
} else if character.is_control() {
1
} else {
UnicodeWidthChar::width(character).unwrap_or(0)
}
}
pub fn line_display_width(line: &str) -> usize {
line.chars().map(char_display_width).sum()
}
pub fn display_column(line: &str, char_col: usize) -> usize {
line.chars().take(char_col).map(char_display_width).sum()
}
pub fn color_for_extension(ext: &str) -> Color {
if ext.is_empty() {
return crate::display::palette().file;
}
extension_color(ext, crate::display::light_background())
}
pub fn extension_color(ext: &str, light: bool) -> Color {
let hash: u32 = ext.bytes().fold(5381u32, |h, b| h.wrapping_mul(33).wrapping_add(b as u32));
let hue: f64 = (hash % 360) as f64;
let (saturation, lightness): (f64, f64) = if light { (0.75, 0.32) } else { (0.7, 0.7) };
let c = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
let x = c * (1.0 - ((hue / 60.0) % 2.0 - 1.0).abs());
let m = lightness - c / 2.0;
let (r1, g1, b1) = match hue as u32 {
0..60 => (c, x, 0.0),
60..120 => (x, c, 0.0),
120..180 => (0.0, c, x),
180..240 => (0.0, x, c),
240..300 => (x, 0.0, c),
_ => (c, 0.0, x),
};
Color::Rgb(((r1 + m) * 255.0) as u8, ((g1 + m) * 255.0) as u8, ((b1 + m) * 255.0) as u8)
}
pub fn printable_name(name: &str) -> String {
if !name.chars().any(char::is_control) {
return name.to_string();
}
name.chars().map(|character| if character.is_control() { '.' } else { character }).collect()
}
pub fn limit_path_string(path: &Path, n: usize) -> String {
let path_string = printable_name(&path.display().to_string());
if display_width(&path_string) <= n {
return path_string;
}
let mut width = 0;
let mut start = path_string.len();
for (index, character) in path_string.char_indices().rev() {
let character_width = UnicodeWidthChar::width(character).unwrap_or(0);
if width + character_width > n {
break;
}
width += character_width;
start = index;
}
format!("...{}", &path_string[start..])
}
#[cfg(test)]
mod text_tests {
use super::*;
#[test]
fn a_size_is_rounded_to_the_largest_unit_under_it() {
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(1023), "1023 B");
assert_eq!(format_size(1024), "1 KiB");
assert_eq!(format_size(10 * 1024 * 1024), "10 MiB");
assert_eq!(format_size(3 << 30), "3 GiB");
assert_eq!(format_size(1u64 << 50), "1024 TiB");
assert_eq!(format_size(1024 * 1024 - 1), "1 MiB");
assert_eq!(format_size(1023 * 1024 + 600), "1 MiB");
assert_eq!(format_size(1023 * 1024 + 400), "1023 KiB");
}
#[test]
fn thousands_are_marked_off_in_threes_from_the_right() {
assert_eq!(grouped(0), "0");
assert_eq!(grouped(999), "999");
assert_eq!(grouped(1000), "1 000");
assert_eq!(grouped(12_345), "12 345");
assert_eq!(grouped(1_234_567), "1 234 567");
assert_eq!(grouped(u64::MAX), "18 446 744 073 709 551 615");
}
#[test]
fn control_characters_in_a_name_are_drawn_as_dots() {
assert_eq!(printable_name("plain.txt"), "plain.txt");
assert_eq!(printable_name("日本語.txt"), "日本語.txt");
assert_eq!(printable_name("a\tb\x1b[2J\n"), "a.b.[2J.");
assert_eq!(display_width(&printable_name("x\x07y")), 3);
}
#[test]
fn a_long_path_keeps_its_end() {
let path = Path::new("/home/user/projects");
assert_eq!(limit_path_string(path, 40), "/home/user/projects");
assert_eq!(limit_path_string(path, 8), "...projects");
assert_eq!(limit_path_string(Path::new("/日本語"), 4), "...本語");
assert_eq!(limit_path_string(Path::new("/日本語"), 5), "...本語");
assert_eq!(limit_path_string(Path::new("/tmp/a\x1bb"), 3), "...a.b");
}
#[test]
fn an_extension_always_gets_the_same_colour() {
assert_eq!(extension_color("rs", false), extension_color("rs", false));
assert_ne!(extension_color("rs", false), extension_color("rs", true));
assert_ne!(extension_color("rs", false), extension_color("toml", false));
}
#[test]
fn a_line_is_made_safe_to_draw_and_measured_as_drawn() {
assert_eq!(printable_line("a\tb"), "a b");
assert_eq!(printable_line("\x1b[31mred"), ".[31mred");
assert_eq!(line_display_width("a\tb"), 6);
assert_eq!(line_display_width("日本"), 4);
}
#[test]
fn a_word_without_braces_is_left_as_it_is() {
assert_eq!(substitute("--wait", OsStr::new("/x")), "--wait");
assert_eq!(substitute("{}{}", OsStr::new("ab")), "abab");
assert_eq!(substitute("", OsStr::new("ab")), "");
}
}
#[cfg(test)]
mod format_tests {
use super::format_modified;
use crate::options::DateFormat;
use std::time::{Duration, SystemTime};
#[test]
fn a_relative_date_counts_back_from_now() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000_000);
let ago = |seconds: u64| format_modified(now - Duration::from_secs(seconds), DateFormat::Relative, now);
assert_eq!(ago(0), "just now");
assert_eq!(ago(59), "just now");
assert_eq!(ago(60), "1 min ago");
assert_eq!(ago(3599), "59 min ago");
assert_eq!(ago(3600 * 5), "5 h ago");
assert_eq!(ago(86_400 * 29), "29 days ago");
assert_eq!(ago(86_400 * 60), "2 mo ago");
assert_eq!(ago(86_400 * 800), "2 yr ago");
assert_eq!(format_modified(now + Duration::from_secs(90), DateFormat::Relative, now), "just now");
}
#[test]
fn the_fixed_formats_fit_their_column() {
let time = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000_000);
assert_eq!(format_modified(time, DateFormat::Short, time).len(), 14);
assert_eq!(format_modified(time, DateFormat::Iso, time).len(), 16);
}
#[test]
fn the_exact_form_follows_the_chosen_order() {
let time = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000_000);
let iso = super::format_exact(time, DateFormat::Iso);
assert_eq!(iso.len(), 19);
assert!(iso.starts_with("20") && iso.as_bytes()[4] == b'-', "{iso}");
let short = super::format_exact(time, DateFormat::Short);
assert_eq!(short.len(), 17);
assert_eq!(short.as_bytes()[2], b'/');
assert_eq!(super::format_exact(time, DateFormat::Relative), short);
}
}
#[cfg(all(test, unix))]
mod substitute_tests {
use super::substitute;
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
#[test]
fn a_path_goes_in_as_the_bytes_it_is() {
let path = OsStr::from_bytes(b"/tmp/caf\xe9.txt");
assert_eq!(substitute("{}", path), path);
assert_eq!(substitute("--file={}", path).as_bytes(), b"--file=/tmp/caf\xe9.txt");
assert_eq!(substitute("{}:{}", path).as_bytes(), b"/tmp/caf\xe9.txt:/tmp/caf\xe9.txt");
assert_eq!(substitute("nvim", path), "nvim");
}
}
#[cfg(test)]
mod column_tests {
use super::*;
#[test]
fn a_column_counts_what_each_character_is_drawn_as() {
assert_eq!(display_column("abc", 2), 2);
assert_eq!(display_column("日本語", 2), 4);
assert_eq!(display_column("\tx", 1), 4);
assert_eq!(display_column("\x1bx", 1), 1);
assert_eq!(display_column("ab", 10), 2);
assert_eq!(line_display_width("a\x07日"), 4);
}
}