use colored::{Color, Colorize};
use std::borrow::Cow;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy)]
pub struct BorderChars {
pub top_left: char,
pub top: char,
pub top_right: char,
pub left: char,
pub right: char,
pub bottom_left: char,
pub bottom: char,
pub bottom_right: char,
}
impl BorderChars {
pub const EMPTY: Self = Self {
top_left: ' ',
top: ' ',
top_right: ' ',
left: ' ',
right: ' ',
bottom_left: ' ',
bottom: ' ',
bottom_right: ' ',
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BorderStyle {
#[default]
None,
Normal,
Rounded,
Double,
Thick,
Hidden,
}
impl BorderStyle {
pub fn chars(&self) -> BorderChars {
match self {
Self::None => BorderChars::EMPTY,
Self::Normal => BorderChars {
top_left: '┌',
top: '─',
top_right: '┐',
left: '│',
right: '│',
bottom_left: '└',
bottom: '─',
bottom_right: '┘',
},
Self::Rounded => BorderChars {
top_left: '╭',
top: '─',
top_right: '╮',
left: '│',
right: '│',
bottom_left: '╰',
bottom: '─',
bottom_right: '╯',
},
Self::Double => BorderChars {
top_left: '╔',
top: '═',
top_right: '╗',
left: '║',
right: '║',
bottom_left: '╚',
bottom: '═',
bottom_right: '╝',
},
Self::Thick => BorderChars {
top_left: '┏',
top: '━',
top_right: '┓',
left: '┃',
right: '┃',
bottom_left: '┗',
bottom: '━',
bottom_right: '┛',
},
Self::Hidden => BorderChars {
top_left: ' ',
top: ' ',
top_right: ' ',
left: ' ',
right: ' ',
bottom_left: ' ',
bottom: ' ',
bottom_right: ' ',
},
}
}
pub fn ascii_chars(&self) -> BorderChars {
match self {
Self::None => BorderChars::EMPTY,
Self::Hidden => BorderChars::EMPTY,
_ => BorderChars {
top_left: '+',
top: '-',
top_right: '+',
left: '|',
right: '|',
bottom_left: '+',
bottom: '-',
bottom_right: '+',
},
}
}
pub fn has_border(&self) -> bool {
!matches!(self, Self::None)
}
pub fn takes_space(&self) -> bool {
!matches!(self, Self::None)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Spacing {
pub top: u16,
pub right: u16,
pub bottom: u16,
pub left: u16,
}
impl Spacing {
pub fn all(v: u16) -> Self {
Self {
top: v,
right: v,
bottom: v,
left: v,
}
}
pub fn horizontal(h: u16) -> Self {
Self {
top: 0,
right: h,
bottom: 0,
left: h,
}
}
pub fn vertical(v: u16) -> Self {
Self {
top: v,
right: 0,
bottom: v,
left: 0,
}
}
pub fn new(top: u16, right: u16, bottom: u16, left: u16) -> Self {
Self {
top,
right,
bottom,
left,
}
}
pub fn horizontal_total(&self) -> u16 {
self.left + self.right
}
pub fn vertical_total(&self) -> u16 {
self.top + self.bottom
}
}
pub type Padding = Spacing;
pub type Margin = Spacing;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Align {
#[default]
Left,
Center,
Right,
}
#[derive(Debug, Clone, Default)]
pub struct BoxStyle {
pub border: BorderStyle,
pub border_color: Option<Color>,
pub padding: Padding,
pub margin: Margin,
pub width: u16,
pub max_width: u16,
pub min_width: u16,
pub align: Align,
pub foreground: Option<Color>,
pub background: Option<Color>,
pub bold: bool,
pub italic: bool,
pub dim: bool,
}
impl BoxStyle {
pub fn new() -> Self {
Self::default()
}
pub fn border(mut self, style: BorderStyle) -> Self {
self.border = style;
self
}
pub fn border_color(mut self, color: Color) -> Self {
self.border_color = Some(color);
self
}
pub fn padding(mut self, padding: Padding) -> Self {
self.padding = padding;
self
}
pub fn margin(mut self, margin: Margin) -> Self {
self.margin = margin;
self
}
pub fn width(mut self, width: u16) -> Self {
self.width = width;
self
}
pub fn max_width(mut self, max_width: u16) -> Self {
self.max_width = max_width;
self
}
pub fn min_width(mut self, min_width: u16) -> Self {
self.min_width = min_width;
self
}
pub fn align(mut self, align: Align) -> Self {
self.align = align;
self
}
pub fn foreground(mut self, color: Color) -> Self {
self.foreground = Some(color);
self
}
pub fn background(mut self, color: Color) -> Self {
self.background = Some(color);
self
}
pub fn bold(mut self) -> Self {
self.bold = true;
self
}
pub fn italic(mut self) -> Self {
self.italic = true;
self
}
pub fn dim(mut self) -> Self {
self.dim = true;
self
}
pub fn render(&self, content: &str, colors_enabled: bool, unicode_enabled: bool) -> String {
let mut result = String::new();
let content_lines: Vec<&str> = content.lines().collect();
let content_width = content_lines
.iter()
.map(|line| UnicodeWidthStr::width(*line))
.max()
.unwrap_or(0) as u16;
let inner_width = self.calculate_inner_width(content_width);
let border_chars = if unicode_enabled {
self.border.chars()
} else {
self.border.ascii_chars()
};
let has_border = self.border.takes_space();
let border_width = if has_border { 2 } else { 0 };
let total_width = inner_width + self.padding.horizontal_total() + border_width;
for _ in 0..self.margin.top {
result.push('\n');
}
if has_border {
let margin_indent = " ".repeat(self.margin.left as usize);
let top_line = format!(
"{}{}{}{}",
margin_indent,
border_chars.top_left,
border_chars
.top
.to_string()
.repeat((total_width - 2) as usize),
border_chars.top_right
);
if colors_enabled {
if let Some(color) = self.border_color {
result.push_str(&top_line.color(color).to_string());
} else {
result.push_str(&top_line);
}
} else {
result.push_str(&top_line);
}
result.push('\n');
}
for _ in 0..self.padding.top {
result.push_str(&self.render_padding_line(
inner_width,
&border_chars,
has_border,
colors_enabled,
));
result.push('\n');
}
for line in &content_lines {
let styled_line = self.render_content_line(
line,
inner_width,
&border_chars,
has_border,
colors_enabled,
);
result.push_str(&styled_line);
result.push('\n');
}
for _ in 0..self.padding.bottom {
result.push_str(&self.render_padding_line(
inner_width,
&border_chars,
has_border,
colors_enabled,
));
result.push('\n');
}
if has_border {
let margin_indent = " ".repeat(self.margin.left as usize);
let bottom_line = format!(
"{}{}{}{}",
margin_indent,
border_chars.bottom_left,
border_chars
.bottom
.to_string()
.repeat((total_width - 2) as usize),
border_chars.bottom_right
);
if colors_enabled {
if let Some(color) = self.border_color {
result.push_str(&bottom_line.color(color).to_string());
} else {
result.push_str(&bottom_line);
}
} else {
result.push_str(&bottom_line);
}
}
for _ in 0..self.margin.bottom {
result.push('\n');
}
if result.ends_with('\n') {
result.pop();
}
result
}
fn calculate_inner_width(&self, content_width: u16) -> u16 {
let mut width = if self.width > 0 {
self.width
} else {
content_width
};
if self.min_width > 0 && width < self.min_width {
width = self.min_width;
}
if self.max_width > 0 && width > self.max_width {
width = self.max_width;
}
width
}
fn render_padding_line(
&self,
inner_width: u16,
border_chars: &BorderChars,
has_border: bool,
colors_enabled: bool,
) -> String {
let margin_indent = " ".repeat(self.margin.left as usize);
let padding_left = " ".repeat(self.padding.left as usize);
let padding_right = " ".repeat(self.padding.right as usize);
let inner_space = " ".repeat(inner_width as usize);
if has_border {
let left_border = border_chars.left.to_string();
let right_border = border_chars.right.to_string();
if colors_enabled {
if let Some(color) = self.border_color {
format!(
"{}{}{}{}{}{}",
margin_indent,
left_border.color(color),
padding_left,
inner_space,
padding_right,
right_border.color(color)
)
} else {
format!(
"{}{}{}{}{}{}",
margin_indent,
left_border,
padding_left,
inner_space,
padding_right,
right_border
)
}
} else {
format!(
"{}{}{}{}{}{}",
margin_indent,
left_border,
padding_left,
inner_space,
padding_right,
right_border
)
}
} else {
format!(
"{}{}{}{}",
margin_indent, padding_left, inner_space, padding_right
)
}
}
fn render_content_line(
&self,
line: &str,
inner_width: u16,
border_chars: &BorderChars,
has_border: bool,
colors_enabled: bool,
) -> String {
let margin_indent = " ".repeat(self.margin.left as usize);
let padding_left = " ".repeat(self.padding.left as usize);
let padding_right = " ".repeat(self.padding.right as usize);
let line_width = UnicodeWidthStr::width(line) as u16;
let space_available = inner_width.saturating_sub(line_width);
let (left_pad, right_pad) = match self.align {
Align::Left => (0, space_available),
Align::Right => (space_available, 0),
Align::Center => {
let left = space_available / 2;
let right = space_available - left;
(left, right)
}
};
let styled_content: Cow<str> = if colors_enabled
&& (self.foreground.is_some()
|| self.background.is_some()
|| self.bold
|| self.italic
|| self.dim)
{
let mut styled = line.normal();
if let Some(fg) = self.foreground {
styled = styled.color(fg);
}
if let Some(bg) = self.background {
styled = styled.on_color(bg);
}
if self.bold {
styled = styled.bold();
}
if self.italic {
styled = styled.italic();
}
if self.dim {
styled = styled.dimmed();
}
Cow::Owned(styled.to_string())
} else {
Cow::Borrowed(line)
};
let inner_content = format!(
"{}{}{}",
" ".repeat(left_pad as usize),
styled_content,
" ".repeat(right_pad as usize)
);
if has_border {
let left_border = border_chars.left.to_string();
let right_border = border_chars.right.to_string();
if colors_enabled {
if let Some(color) = self.border_color {
format!(
"{}{}{}{}{}{}",
margin_indent,
left_border.color(color),
padding_left,
inner_content,
padding_right,
right_border.color(color)
)
} else {
format!(
"{}{}{}{}{}{}",
margin_indent,
left_border,
padding_left,
inner_content,
padding_right,
right_border
)
}
} else {
format!(
"{}{}{}{}{}{}",
margin_indent,
left_border,
padding_left,
inner_content,
padding_right,
right_border
)
}
} else {
format!(
"{}{}{}{}",
margin_indent, padding_left, inner_content, padding_right
)
}
}
}
pub fn join_horizontal(items: &[&str], spacing: u16) -> String {
if items.is_empty() {
return String::new();
}
let item_lines: Vec<Vec<&str>> = items.iter().map(|s| s.lines().collect()).collect();
let max_height = item_lines
.iter()
.map(|lines| lines.len())
.max()
.unwrap_or(0);
let item_widths: Vec<usize> = item_lines
.iter()
.map(|lines| {
lines
.iter()
.map(|line| UnicodeWidthStr::width(*line))
.max()
.unwrap_or(0)
})
.collect();
let mut result = Vec::new();
let spacer = " ".repeat(spacing as usize);
for row in 0..max_height {
let mut line_parts = Vec::new();
for (i, lines) in item_lines.iter().enumerate() {
let width = item_widths[i];
let content = lines.get(row).copied().unwrap_or("");
let content_width = UnicodeWidthStr::width(content);
let padding = width.saturating_sub(content_width);
line_parts.push(format!("{}{}", content, " ".repeat(padding)));
}
result.push(line_parts.join(&spacer));
}
result.join("\n")
}
pub fn join_vertical(items: &[&str]) -> String {
items.join("\n")
}
pub fn place(width: u16, height: u16, h_align: Align, v_align: Align, content: &str) -> String {
let lines: Vec<&str> = content.lines().collect();
let content_height = lines.len() as u16;
let content_width = lines
.iter()
.map(|line| UnicodeWidthStr::width(*line))
.max()
.unwrap_or(0) as u16;
let (top_pad, bottom_pad) = match v_align {
Align::Left => (0, height.saturating_sub(content_height)), Align::Center => {
let pad = height.saturating_sub(content_height);
let top = pad / 2;
(top, pad - top)
}
Align::Right => (height.saturating_sub(content_height), 0), };
let h_space = width.saturating_sub(content_width);
let (left_pad, right_pad) = match h_align {
Align::Left => (0, h_space),
Align::Center => {
let left = h_space / 2;
(left, h_space - left)
}
Align::Right => (h_space, 0),
};
let mut result = Vec::new();
for _ in 0..top_pad {
result.push(" ".repeat(width as usize));
}
for line in &lines {
let line_width = UnicodeWidthStr::width(*line) as u16;
let extra_right = (content_width - line_width) + right_pad;
result.push(format!(
"{}{}{}",
" ".repeat(left_pad as usize),
line,
" ".repeat(extra_right as usize)
));
}
for _ in 0..bottom_pad {
result.push(" ".repeat(width as usize));
}
result.join("\n")
}
pub mod presets {
use super::*;
pub fn info_box() -> BoxStyle {
BoxStyle::new()
.border(BorderStyle::Rounded)
.border_color(Color::Cyan)
.padding(Padding::horizontal(1))
}
pub fn warning_box() -> BoxStyle {
BoxStyle::new()
.border(BorderStyle::Rounded)
.border_color(Color::Yellow)
.foreground(Color::Yellow)
.padding(Padding::horizontal(1))
}
pub fn error_box() -> BoxStyle {
BoxStyle::new()
.border(BorderStyle::Rounded)
.border_color(Color::Red)
.foreground(Color::Red)
.padding(Padding::horizontal(1))
}
pub fn success_box() -> BoxStyle {
BoxStyle::new()
.border(BorderStyle::Rounded)
.border_color(Color::Green)
.foreground(Color::Green)
.padding(Padding::horizontal(1))
}
pub fn title_box() -> BoxStyle {
BoxStyle::new()
.border(BorderStyle::Double)
.padding(Padding::new(0, 2, 0, 2))
.bold()
}
pub fn dialog_box() -> BoxStyle {
BoxStyle::new()
.border(BorderStyle::Thick)
.padding(Padding::all(1))
.align(Align::Center)
}
pub fn status_box() -> BoxStyle {
BoxStyle::new()
.border(BorderStyle::Normal)
.padding(Padding::horizontal(1))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tracing::info;
fn log_test_start(name: &str) {
info!("TEST START: {}", name);
}
#[test]
fn test_border_style_chars() {
log_test_start("test_border_style_chars");
let normal = BorderStyle::Normal.chars();
assert_eq!(normal.top_left, '┌');
assert_eq!(normal.top_right, '┐');
let rounded = BorderStyle::Rounded.chars();
assert_eq!(rounded.top_left, '╭');
assert_eq!(rounded.bottom_right, '╯');
let double = BorderStyle::Double.chars();
assert_eq!(double.left, '║');
assert_eq!(double.top, '═');
}
#[test]
fn test_border_style_ascii_fallback() {
log_test_start("test_border_style_ascii_fallback");
let ascii = BorderStyle::Normal.ascii_chars();
assert_eq!(ascii.top_left, '+');
assert_eq!(ascii.top, '-');
assert_eq!(ascii.left, '|');
}
#[test]
fn test_spacing_all() {
log_test_start("test_spacing_all");
let spacing = Spacing::all(2);
assert_eq!(spacing.top, 2);
assert_eq!(spacing.right, 2);
assert_eq!(spacing.bottom, 2);
assert_eq!(spacing.left, 2);
}
#[test]
fn test_spacing_horizontal() {
log_test_start("test_spacing_horizontal");
let spacing = Spacing::horizontal(3);
assert_eq!(spacing.top, 0);
assert_eq!(spacing.right, 3);
assert_eq!(spacing.bottom, 0);
assert_eq!(spacing.left, 3);
assert_eq!(spacing.horizontal_total(), 6);
}
#[test]
fn test_spacing_vertical() {
log_test_start("test_spacing_vertical");
let spacing = Spacing::vertical(2);
assert_eq!(spacing.top, 2);
assert_eq!(spacing.right, 0);
assert_eq!(spacing.bottom, 2);
assert_eq!(spacing.left, 0);
assert_eq!(spacing.vertical_total(), 4);
}
#[test]
fn test_box_style_builder() {
log_test_start("test_box_style_builder");
let style = BoxStyle::new()
.border(BorderStyle::Rounded)
.border_color(Color::Cyan)
.padding(Padding::all(1))
.width(40)
.align(Align::Center)
.bold();
assert_eq!(style.border, BorderStyle::Rounded);
assert_eq!(style.border_color, Some(Color::Cyan));
assert_eq!(style.padding.top, 1);
assert_eq!(style.width, 40);
assert_eq!(style.align, Align::Center);
assert!(style.bold);
}
#[test]
fn test_render_simple_box_no_colors() {
log_test_start("test_render_simple_box_no_colors");
let style = BoxStyle::new().border(BorderStyle::Normal).width(10);
let output = style.render("Hello", false, true);
assert!(output.contains('┌'));
assert!(output.contains('└'));
assert!(output.contains('│'));
assert!(output.contains("Hello"));
}
#[test]
fn test_render_box_ascii_fallback() {
log_test_start("test_render_box_ascii_fallback");
let style = BoxStyle::new().border(BorderStyle::Rounded).width(10);
let output = style.render("Hi", false, false);
assert!(output.contains('+'));
assert!(output.contains('-'));
assert!(output.contains('|'));
assert!(!output.contains('╭')); }
#[test]
fn test_render_box_with_padding() {
log_test_start("test_render_box_with_padding");
let style = BoxStyle::new()
.border(BorderStyle::Normal)
.padding(Padding::all(1))
.width(10);
let output = style.render("Hi", false, true);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 5);
}
#[test]
fn test_render_multiline_content() {
log_test_start("test_render_multiline_content");
let style = BoxStyle::new().border(BorderStyle::Normal).width(20);
let output = style.render("Line 1\nLine 2\nLine 3", false, true);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 5);
assert!(output.contains("Line 1"));
assert!(output.contains("Line 2"));
assert!(output.contains("Line 3"));
}
#[test]
fn test_alignment_left() {
log_test_start("test_alignment_left");
let style = BoxStyle::new()
.border(BorderStyle::Normal)
.align(Align::Left)
.width(20);
let output = style.render("Hi", false, true);
assert!(output.contains("│Hi │"));
}
#[test]
fn test_alignment_center() {
log_test_start("test_alignment_center");
let style = BoxStyle::new()
.border(BorderStyle::Normal)
.align(Align::Center)
.width(20);
let output = style.render("Hi", false, true);
assert!(output.contains("│ Hi │"));
}
#[test]
fn test_alignment_right() {
log_test_start("test_alignment_right");
let style = BoxStyle::new()
.border(BorderStyle::Normal)
.align(Align::Right)
.width(20);
let output = style.render("Hi", false, true);
assert!(output.contains("│ Hi│"));
}
#[test]
fn test_no_border() {
log_test_start("test_no_border");
let style = BoxStyle::new().border(BorderStyle::None).width(10);
let output = style.render("Hello", false, true);
assert!(!output.contains('│'));
assert!(!output.contains('┌'));
assert!(output.contains("Hello"));
}
#[test]
fn test_join_horizontal() {
log_test_start("test_join_horizontal");
let box1 = "A\nB";
let box2 = "1\n2";
let joined = join_horizontal(&[box1, box2], 2);
let lines: Vec<&str> = joined.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("A"));
assert!(lines[0].contains("1"));
assert!(lines[1].contains("B"));
assert!(lines[1].contains("2"));
}
#[test]
fn test_join_horizontal_different_heights() {
log_test_start("test_join_horizontal_different_heights");
let box1 = "A\nB\nC";
let box2 = "1";
let joined = join_horizontal(&[box1, box2], 1);
let lines: Vec<&str> = joined.lines().collect();
assert_eq!(lines.len(), 3); }
#[test]
fn test_join_vertical() {
log_test_start("test_join_vertical");
let box1 = "Top";
let box2 = "Bottom";
let joined = join_vertical(&[box1, box2]);
assert!(joined.contains("Top"));
assert!(joined.contains("Bottom"));
assert!(joined.contains('\n'));
}
#[test]
fn test_place_center() {
log_test_start("test_place_center");
let content = "X";
let placed = place(5, 3, Align::Center, Align::Center, content);
let lines: Vec<&str> = placed.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[1].trim(), "X");
}
#[test]
fn test_presets() {
log_test_start("test_presets");
let info = presets::info_box();
assert_eq!(info.border, BorderStyle::Rounded);
assert_eq!(info.border_color, Some(Color::Cyan));
let error = presets::error_box();
assert_eq!(error.foreground, Some(Color::Red));
let dialog = presets::dialog_box();
assert_eq!(dialog.border, BorderStyle::Thick);
assert_eq!(dialog.align, Align::Center);
}
#[test]
fn test_margin_applied() {
log_test_start("test_margin_applied");
let style = BoxStyle::new()
.border(BorderStyle::Normal)
.margin(Margin::new(1, 0, 1, 2))
.width(10);
let output = style.render("Hi", false, true);
assert!(output.contains(" ┌"));
}
#[test]
fn test_min_width() {
log_test_start("test_min_width");
let style = BoxStyle::new().border(BorderStyle::Normal).min_width(20);
let output = style.render("Hi", false, true);
let first_line = output.lines().next().unwrap();
assert!(first_line.len() >= 22); }
#[test]
fn test_border_has_border() {
log_test_start("test_border_has_border");
assert!(!BorderStyle::None.has_border());
assert!(BorderStyle::Normal.has_border());
assert!(BorderStyle::Rounded.has_border());
assert!(BorderStyle::Hidden.has_border()); }
}