use compact_str::CompactString;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::wrap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoxStyle {
Rounded,
#[allow(dead_code)]
Double,
}
impl BoxStyle {
#[allow(dead_code)]
pub fn top_left(self) -> char {
match self {
BoxStyle::Rounded => '╭',
BoxStyle::Double => '╔',
}
}
pub fn top_right(self) -> char {
match self {
BoxStyle::Rounded => '╮',
BoxStyle::Double => '╗',
}
}
pub fn bottom_left(self) -> char {
match self {
BoxStyle::Rounded => '╰',
BoxStyle::Double => '╚',
}
}
pub fn bottom_right(self) -> char {
match self {
BoxStyle::Rounded => '╯',
BoxStyle::Double => '╝',
}
}
pub fn horizontal(self) -> char {
match self {
BoxStyle::Rounded => '─',
BoxStyle::Double => '═',
}
}
pub fn vertical(self) -> char {
match self {
BoxStyle::Rounded => '│',
BoxStyle::Double => '║',
}
}
pub fn tee_left(self) -> char {
match self {
BoxStyle::Rounded => '├',
BoxStyle::Double => '╠',
}
}
pub fn tee_right(self) -> char {
match self {
BoxStyle::Rounded => '┤',
BoxStyle::Double => '╣',
}
}
}
#[allow(dead_code)]
pub fn top(style: BoxStyle, title: &str, total_w: usize) -> String {
if title.is_empty() {
let inner = total_w.saturating_sub(2);
return format!(
"{}{}{}",
style.top_left(),
style.horizontal().to_string().repeat(inner),
style.top_right(),
);
}
let hch = style.horizontal();
let hstr = hch.to_string();
match style {
BoxStyle::Double => {
let bracketed = format!("[{title}]");
let bracketed_w = UnicodeWidthStr::width(bracketed.as_str());
let fill = total_w.saturating_sub(2).saturating_sub(bracketed_w);
let left = fill / 2;
let right = fill - left;
format!(
"{}{}{}{}{}",
style.top_left(),
hstr.repeat(left),
bracketed,
hstr.repeat(right),
style.top_right(),
)
}
BoxStyle::Rounded => {
let title_w = UnicodeWidthStr::width(title);
const OVERHEAD: usize = 7;
let fill = total_w.saturating_sub(OVERHEAD).saturating_sub(title_w);
format!(
"{}{} {} {}{}{}{}",
style.top_left(),
hstr,
title,
hstr,
hstr.repeat(fill),
hstr,
style.top_right(),
)
}
}
}
pub fn bottom(style: BoxStyle, total_w: usize) -> String {
let inner = total_w.saturating_sub(2); format!(
"{}{}{}",
style.bottom_left(),
style.horizontal().to_string().repeat(inner),
style.bottom_right(),
)
}
#[allow(dead_code)]
pub fn divider(style: BoxStyle, total_w: usize) -> String {
let inner = total_w.saturating_sub(2);
format!(
"{}{}{}",
style.tee_left(),
style.horizontal().to_string().repeat(inner),
style.tee_right(),
)
}
pub fn row(style: BoxStyle, content: &str, total_w: usize) -> String {
let inner = total_w.saturating_sub(4);
let expanded = expand_tabs(content, 4);
let total_visible = UnicodeWidthStr::width(expanded.as_str());
let (trimmed, trimmed_w): (String, usize) = if total_visible <= inner {
(expanded.clone(), total_visible)
} else if inner == 0 {
(String::new(), 0)
} else {
let budget = inner.saturating_sub(1);
let mut out = String::with_capacity(expanded.len());
let mut used = 0;
for ch in expanded.chars() {
let w = ch.width().unwrap_or(0);
if used + w > budget {
break;
}
out.push(ch);
used += w;
}
out.push('…');
(out, used + 1)
};
let pad = inner.saturating_sub(trimmed_w);
format!(
"{} {}{} {}",
style.vertical(),
trimmed,
" ".repeat(pad),
style.vertical(),
)
}
pub fn row_with_bg(style: BoxStyle, content: &str, total_w: usize, bg_idx: u8) -> String {
let inner = total_w.saturating_sub(4);
let expanded = expand_tabs(content, 4);
let total_visible = UnicodeWidthStr::width(expanded.as_str());
let (trimmed, trimmed_w): (String, usize) = if total_visible <= inner {
(expanded.clone(), total_visible)
} else if inner == 0 {
(String::new(), 0)
} else {
let budget = inner.saturating_sub(1);
let mut out = String::with_capacity(expanded.len());
let mut used = 0;
for ch in expanded.chars() {
let w = ch.width().unwrap_or(0);
if used + w > budget {
break;
}
out.push(ch);
used += w;
}
out.push('…');
(out, used + 1)
};
let pad = inner.saturating_sub(trimmed_w);
let (bg_open, bg_close) = diff_bg_escapes(bg_idx, crate::ui::theme::no_color());
format!(
"{} {}{}{}{} {}",
style.vertical(),
bg_open,
trimmed,
" ".repeat(pad),
bg_close,
style.vertical(),
)
}
fn diff_bg_escapes(bg_idx: u8, no_color: bool) -> (String, String) {
if no_color {
(String::new(), String::new())
} else {
(format!("\x1b[48;5;{bg_idx}m"), String::from("\x1b[49m"))
}
}
pub fn expand_tabs(s: &str, tab_stop: usize) -> String {
if !s.contains('\t') {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + 8);
let mut col = 0usize;
for ch in s.chars() {
if ch == '\t' {
let pad = tab_stop - (col % tab_stop);
for _ in 0..pad {
out.push(' ');
}
col += pad;
} else {
out.push(ch);
col += ch.width().unwrap_or(0);
}
}
out
}
#[allow(dead_code)]
pub struct BoxBuilder {
style: BoxStyle,
width: usize,
title: String,
rows: Vec<RowKind>,
}
#[allow(dead_code)]
enum RowKind {
Text(CompactString),
Divider,
}
impl BoxBuilder {
#[allow(dead_code)]
pub fn new(style: BoxStyle, title: impl Into<String>, width: usize) -> Self {
Self {
style,
width: width.max(8),
title: title.into(),
rows: Vec::new(),
}
}
#[allow(dead_code)]
pub fn row(mut self, content: impl AsRef<str>) -> Self {
let inner = self.width.saturating_sub(4);
let s = content.as_ref();
if s.is_empty() {
self.rows.push(RowKind::Text(CompactString::new("")));
return self;
}
for logical in s.split('\n') {
if logical.is_empty() {
self.rows.push(RowKind::Text(CompactString::new("")));
continue;
}
for chunk in wrap::soft_wrap(logical, inner, "") {
self.rows.push(RowKind::Text(CompactString::from(chunk)));
}
}
self
}
#[allow(dead_code)]
pub fn row_labelled(mut self, label: &str, sep: &str, value: &str) -> Self {
let inner = self.width.saturating_sub(4);
let prefix = format!("{label}{sep}");
let prefix_w = UnicodeWidthStr::width(prefix.as_str());
let cont_indent: String = " ".repeat(prefix_w);
let combined = format!("{prefix}{value}");
for chunk in wrap::soft_wrap(&combined, inner, &cont_indent) {
self.rows.push(RowKind::Text(CompactString::from(chunk)));
}
self
}
#[allow(dead_code)]
pub fn divider(mut self) -> Self {
self.rows.push(RowKind::Divider);
self
}
#[allow(dead_code)]
pub fn build(self) -> Vec<String> {
let mut out: Vec<String> = Vec::with_capacity(self.rows.len() + 2);
out.push(top(self.style, &self.title, self.width));
for rk in self.rows {
match rk {
RowKind::Text(s) => out.push(row(self.style, &s, self.width)),
RowKind::Divider => out.push(divider(self.style, self.width)),
}
}
out.push(bottom(self.style, self.width));
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diff_bg_escapes_respect_no_color() {
let (open, close) = diff_bg_escapes(22, false);
assert_eq!(open, "\x1b[48;5;22m");
assert_eq!(close, "\x1b[49m");
let (open, close) = diff_bg_escapes(22, true);
assert!(open.is_empty(), "no-color must drop the bg-open escape");
assert!(close.is_empty(), "no-color must drop the bg-close escape");
let row_no_color = {
let (o, c) = diff_bg_escapes(52, true);
format!("│ {o}+ added{c} │")
};
assert!(
!row_no_color.contains('\x1b'),
"no escapes under no-color: {row_no_color:?}"
);
}
#[test]
fn frame_helpers_match_total_width() {
for w in [12, 60, 120usize] {
let t = top(BoxStyle::Rounded, "TEST", w);
let b = bottom(BoxStyle::Rounded, w);
let d = divider(BoxStyle::Rounded, w);
assert_eq!(UnicodeWidthStr::width(t.as_str()), w, "top width@{w}");
assert_eq!(UnicodeWidthStr::width(b.as_str()), w, "bottom width@{w}");
assert_eq!(UnicodeWidthStr::width(d.as_str()), w, "divider width@{w}");
}
}
#[test]
fn row_width_invariant() {
let w = 30;
for input in &["short", "exactlyfittingrow", &"x".repeat(100)] {
let r = row(BoxStyle::Rounded, input, w);
assert_eq!(UnicodeWidthStr::width(r.as_str()), w, "row({input:?})@{w}");
}
}
#[test]
fn row_handles_tabs() {
let w = 40;
let r = row(BoxStyle::Rounded, "a\tb", w);
assert_eq!(UnicodeWidthStr::width(r.as_str()), w);
assert!(!r.contains('\t'));
}
#[test]
fn row_handles_cjk() {
let w = 30;
let r = row(BoxStyle::Rounded, "中文测试", w);
assert_eq!(UnicodeWidthStr::width(r.as_str()), w);
}
#[test]
fn builder_produces_well_formed_box() {
let out = BoxBuilder::new(BoxStyle::Rounded, "TITLE", 40)
.row("first line")
.row("second line")
.divider()
.row("after divider")
.build();
assert!(out.len() >= 5);
assert!(out[0].starts_with('╭'));
assert!(out.last().unwrap().starts_with('╰'));
for line in &out {
assert_eq!(UnicodeWidthStr::width(line.as_str()), 40);
}
}
#[test]
fn builder_soft_wraps_long_rows() {
let long = "the quick brown fox jumps over the lazy dog repeatedly";
let out = BoxBuilder::new(BoxStyle::Rounded, "T", 30)
.row(long)
.build();
let content_rows = out.len() - 2;
assert!(content_rows >= 2, "expected wrap, got {content_rows} rows");
for line in &out[1..out.len() - 1] {
assert!(!line.contains('…'), "row truncated unexpectedly: {line}");
}
}
#[test]
fn expand_tabs_aligned_to_stop() {
assert_eq!(expand_tabs("a\tb", 4), "a b");
assert_eq!(expand_tabs("ab\tc", 4), "ab c");
assert_eq!(expand_tabs("abc\td", 4), "abc d");
assert_eq!(expand_tabs("abcd\te", 4), "abcd e");
}
#[test]
fn row_with_bg_width_invariant() {
let w = 30;
for input in &["+ added", "- 中文测试", "+\tindented", "- 🚀"] {
let r = row_with_bg(BoxStyle::Rounded, input, w, 22);
let visible = super::super::wrap::visible_width(&r);
assert_eq!(visible, w, "row_with_bg({input:?})@{w}");
}
}
#[test]
fn top_empty_title_omits_title_slot() {
let w = 20;
let t = top(BoxStyle::Rounded, "", w);
assert_eq!(UnicodeWidthStr::width(t.as_str()), w);
assert!(t.starts_with('╭'));
assert!(t.ends_with('╮'));
assert!(!t.contains(' '));
}
#[test]
fn builder_splits_embedded_newlines() {
let out = BoxBuilder::new(BoxStyle::Rounded, "T", 30)
.row("first\nsecond\nthird")
.build();
assert!(out.len() >= 5, "got {} rows", out.len());
for line in &out {
assert!(!line.contains('\n'), "row leaked newline: {line:?}");
}
let body: Vec<&String> = out[1..out.len() - 1].iter().collect();
assert!(body.iter().any(|l| l.contains("first")));
assert!(body.iter().any(|l| l.contains("second")));
assert!(body.iter().any(|l| l.contains("third")));
}
#[test]
fn builder_row_labelled_indents_continuation() {
let out = BoxBuilder::new(BoxStyle::Rounded, "T", 40)
.row_labelled("args", ": ", &"x".repeat(80))
.build();
let body: Vec<&String> = out[1..out.len() - 1].iter().collect();
assert!(body.len() >= 2);
assert!(body[0].contains("args: "), "first row: {:?}", body[0]);
for cont in &body[1..] {
let after_border = cont.split_once("│ ").map(|(_, r)| r).unwrap_or(cont);
assert!(
after_border.starts_with(" "),
"continuation row missing indent: {cont:?}",
);
}
}
}