1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
//! ANSI-aware column alignment for CLI tables.
//!
//! Hand-rolled (no `comfy-table`) to keep the lightweight, borderless "polished
//! plain" look of [`crate::style`]. Each [`Cell`] carries the *plain* text used
//! for width math and the *styled* text actually printed, so ANSI escape bytes
//! never inflate column widths. Padding spaces have no color, so they're simply
//! appended (left-align) or prepended (right-align) to the styled string.
use unicode_width::UnicodeWidthStr;
/// Horizontal alignment within a column.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Left,
Right,
}
/// One table cell: `plain` drives width math, `styled` is what gets printed.
#[derive(Debug, Clone)]
pub struct Cell {
plain: String,
styled: String,
align: Align,
}
impl Cell {
/// Left-aligned cell whose styled form differs from its plain form.
pub fn new(plain: impl Into<String>, styled: impl Into<String>) -> Self {
Cell {
plain: plain.into(),
styled: styled.into(),
align: Align::Left,
}
}
/// Right-aligned variant (for numeric columns: ports, latency, bytes).
pub fn right(plain: impl Into<String>, styled: impl Into<String>) -> Self {
Cell {
align: Align::Right,
..Cell::new(plain, styled)
}
}
/// Unstyled cell — plain and styled are identical.
pub fn plain(text: impl Into<String> + Clone) -> Self {
Cell::new(text.clone(), text)
}
fn width(&self) -> usize {
UnicodeWidthStr::width(self.plain.as_str())
}
}
/// Render `rows` as space-aligned columns separated by `gap` spaces.
///
/// Column widths are the max visible width of each column's cells. Returns a
/// newline-terminated multi-line string; ragged rows (fewer cells) are fine.
pub fn columns(rows: &[Vec<Cell>], gap: usize) -> String {
let ncols = rows.iter().map(Vec::len).max().unwrap_or(0);
let mut widths = vec![0usize; ncols];
for row in rows {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(cell.width());
}
}
let sep = " ".repeat(gap);
let mut out = String::new();
for row in rows {
let mut line = String::new();
for (i, cell) in row.iter().enumerate() {
// The final cell in a row needs no trailing pad.
let is_last = i + 1 == row.len();
let pad = widths[i].saturating_sub(cell.width());
if i > 0 {
line.push_str(&sep);
}
match cell.align {
Align::Left => {
line.push_str(&cell.styled);
if !is_last {
line.push_str(&" ".repeat(pad));
}
}
Align::Right => {
line.push_str(&" ".repeat(pad));
line.push_str(&cell.styled);
}
}
}
// Trim trailing spaces that a right-aligned last column can't produce
// but a left-aligned padding never added anyway.
out.push_str(line.trim_end());
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aligns_plain_columns() {
let rows = vec![
vec![Cell::plain("a"), Cell::plain("bbb")],
vec![Cell::plain("aaa"), Cell::plain("b")],
];
let out = columns(&rows, 2);
assert_eq!(out, "a bbb\naaa b\n");
}
#[test]
fn width_ignores_ansi_escapes() {
// Styled cell is wider in bytes but 1 col visually; the next column must
// still align with a plain 1-wide cell in the other row.
let styled = "\x1b[38;5;42m●\x1b[0m";
let rows = vec![
vec![Cell::new("●", styled), Cell::plain("online")],
vec![Cell::plain("X"), Cell::plain("x")],
];
let out = columns(&rows, 1);
let lines: Vec<&str> = out.lines().collect();
// Both second columns start at the same visible offset (after 1 glyph +
// 1 gap). Strip ANSI to compare positions.
assert!(lines[0].ends_with("online"));
assert!(lines[1] == "X x");
}
#[test]
fn right_align_pads_left() {
let rows = vec![
vec![Cell::plain("host"), Cell::right("12ms", "12ms")],
vec![Cell::plain("h"), Cell::right("8ms", "8ms")],
];
let out = columns(&rows, 1);
assert_eq!(out, "host 12ms\nh 8ms\n");
}
#[test]
fn empty_input() {
assert_eq!(columns(&[], 2), "");
}
}