use crate::color::TerminalColor;
use crate::renderer::Renderer;
use crate::security::safe_repeat;
use crate::utils::{width as display_width, width_visible as visible_width};
#[derive(Debug)]
pub struct Whitespace {
re: Renderer,
style: String, chars: String,
}
pub fn new_whitespace(r: &Renderer, opts: &[WhitespaceOption]) -> Whitespace {
let mut w = Whitespace {
re: r.clone(),
style: String::new(), chars: String::new(),
};
for opt in opts {
(opt)(&mut w);
}
w
}
impl Whitespace {
pub fn render(&self, width: usize) -> String {
let chars = if self.chars.is_empty() {
" "
} else {
&self.chars
};
let runes: Vec<char> = chars.chars().collect();
let mut j: usize = 0;
let mut output = String::new();
let mut i: usize = 0;
while i < width {
let ch = runes[j];
let ch_width = display_width(&ch.to_string());
if i + ch_width > width {
break;
}
output.push(ch);
j += 1;
if j >= runes.len() {
j = 0;
}
i += ch_width;
}
let content_width = visible_width(&output);
let short = width.saturating_sub(content_width);
if short > 0 {
output.push_str(&safe_repeat(' ', short));
}
if !self.style.is_empty() {
format!("{}{}\x1b[0m", self.style, output)
} else {
output
}
}
}
pub type WhitespaceOption = Box<dyn Fn(&mut Whitespace)>;
pub fn with_whitespace_foreground<C: TerminalColor + 'static>(c: C) -> WhitespaceOption {
Box::new(move |w: &mut Whitespace| {
let fg_color = c.token(&w.re);
if fg_color.is_empty() {
return;
}
let new_seg = if let Some(hex) = fg_color.strip_prefix('#') {
let (r, g, b) = if hex.len() >= 6 {
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
(r, g, b)
} else {
(0, 0, 0)
};
format!("38;2;{};{};{}", r, g, b)
} else {
format!("38;5;{}", fg_color)
};
if w.style.is_empty() {
w.style = format!("\x1b[{}m", new_seg);
} else {
let base = w.style.trim_end_matches('m');
w.style = format!("{};{}m", base, new_seg);
}
})
}
pub fn with_whitespace_background<C: TerminalColor + 'static>(c: C) -> WhitespaceOption {
Box::new(move |w: &mut Whitespace| {
let bg_color = c.token(&w.re);
if bg_color.is_empty() {
return;
}
let new_seg = if let Some(hex) = bg_color.strip_prefix('#') {
let (r, g, b) = if hex.len() >= 6 {
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
(r, g, b)
} else {
(0, 0, 0)
};
format!("48;2;{};{};{}", r, g, b)
} else {
format!("48;5;{}", bg_color)
};
if w.style.is_empty() {
w.style = format!("\x1b[{}m", new_seg);
} else {
let base = w.style.trim_end_matches('m');
w.style = format!("{};{}m", base, new_seg);
}
})
}
pub fn with_whitespace_underline() -> WhitespaceOption {
Box::new(move |w: &mut Whitespace| {
if w.style.is_empty() {
w.style = "\x1b[4m".to_string();
} else {
let base = w.style.trim_end_matches('m');
w.style = format!("{};4m", base);
}
})
}
pub fn with_whitespace_strikethrough() -> WhitespaceOption {
Box::new(move |w: &mut Whitespace| {
if w.style.is_empty() {
w.style = "\x1b[9m".to_string();
} else {
let base = w.style.trim_end_matches('m');
w.style = format!("{};9m", base);
}
})
}
pub fn with_whitespace_chars(s: &str) -> WhitespaceOption {
let chars = s.to_string();
Box::new(move |w: &mut Whitespace| {
w.chars = chars.clone();
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::Color;
use crate::renderer::{ColorProfileKind, Renderer};
#[test]
fn test_whitespace_basic_render() {
let renderer = Renderer::new();
let ws = new_whitespace(&renderer, &[]);
let result = ws.render(5);
assert_eq!(result, " ");
}
#[test]
fn test_whitespace_with_custom_chars() {
let renderer = Renderer::new();
let ws = new_whitespace(&renderer, &[with_whitespace_chars(".")]);
let result = ws.render(3);
assert_eq!(result, "...");
}
#[test]
fn test_whitespace_with_foreground_color() {
let mut renderer = Renderer::new();
renderer.set_color_profile(ColorProfileKind::ANSI256);
let color = Color("9".to_string());
let ws = new_whitespace(&renderer, &[with_whitespace_foreground(color)]);
let result = ws.render(3);
assert!(result.starts_with("\x1b[38;5;9m"));
assert!(result.ends_with("\x1b[0m"));
assert!(result.contains(" "));
}
#[test]
fn test_whitespace_matches_go_algorithm() {
let renderer = Renderer::new();
let ws = new_whitespace(&renderer, &[with_whitespace_chars("ab")]);
let result = ws.render(5);
assert_eq!(result, "ababa");
}
#[test]
fn test_whitespace_struct_matches_go() {
let renderer = Renderer::new();
let ws = new_whitespace(
&renderer,
&[
with_whitespace_chars("*"),
with_whitespace_foreground(Color("1".to_string())),
],
);
let result = ws.render(2);
assert!(result.contains("**"));
assert!(
result.len() >= 2,
"Result should be at least 2 characters, got: '{}'",
result
);
assert_eq!(ws.chars, "*");
}
#[test]
fn test_whitespace_combined_fg_bg_builds_complete_sgr() {
let mut renderer = Renderer::new();
renderer.set_color_profile(ColorProfileKind::ANSI256);
let ws = new_whitespace(
&renderer,
&[
with_whitespace_foreground(Color("1".to_string())),
with_whitespace_background(Color("2".to_string())),
],
);
let out = ws.render(1);
let prefix = "\x1b[38;5;1;48;5;2m";
assert!(out.starts_with(prefix));
assert!(out.ends_with("\x1b[0m"));
assert!(out.len() > prefix.len() + "\x1b[0m".len());
}
}