use crate::cells::cell_len;
use crate::console::{Console, ConsoleOptions};
use crate::renderables::Renderable;
use crate::segment::Segment;
use crate::style::Style;
use super::align::{Align, AlignMethod};
#[derive(Debug, Clone)]
pub struct Columns<'a> {
items: Vec<Vec<Segment<'a>>>,
column_count: Option<usize>,
gutter: usize,
expand: bool,
equal_width: bool,
align: AlignMethod,
padding: usize,
gutter_style: Style,
max_width: Option<usize>,
}
impl Default for Columns<'_> {
fn default() -> Self {
Self {
items: Vec::new(),
column_count: None,
gutter: 2,
expand: true,
equal_width: false,
align: AlignMethod::Left,
padding: 0,
gutter_style: Style::new(),
max_width: None,
}
}
}
impl<'a> Columns<'a> {
#[must_use]
pub fn new(items: Vec<Vec<Segment<'a>>>) -> Self {
Self {
items,
..Default::default()
}
}
#[must_use]
pub fn from_strings(items: &[&'a str]) -> Self {
let segments: Vec<Vec<Segment<'a>>> =
items.iter().map(|s| vec![Segment::new(*s, None)]).collect();
Self::new(segments)
}
#[must_use]
pub fn column_count(mut self, count: usize) -> Self {
self.column_count = Some(count.max(1));
self
}
#[must_use]
pub fn gutter(mut self, gutter: usize) -> Self {
self.gutter = gutter;
self
}
#[must_use]
pub fn expand(mut self, expand: bool) -> Self {
self.expand = expand;
self
}
#[must_use]
pub fn equal_width(mut self, equal: bool) -> Self {
self.equal_width = equal;
self
}
#[must_use]
pub fn align(mut self, align: AlignMethod) -> Self {
self.align = align;
self
}
#[must_use]
pub fn padding(mut self, padding: usize) -> Self {
self.padding = padding;
self
}
#[must_use]
pub fn gutter_style(mut self, style: Style) -> Self {
self.gutter_style = style;
self
}
#[must_use]
pub fn max_width(mut self, width: usize) -> Self {
self.max_width = Some(width);
self
}
fn item_width(item: &[Segment<'_>]) -> usize {
item.iter().map(|s| cell_len(&s.text)).sum()
}
fn calculate_column_widths(&self, total_width: usize, num_columns: usize) -> Vec<usize> {
if num_columns == 0 || self.items.is_empty() {
return vec![];
}
let total_gutter = self.gutter * (num_columns - 1);
let available_width = total_width.saturating_sub(total_gutter);
if self.equal_width {
let column_width = available_width / num_columns;
vec![column_width; num_columns]
} else {
let mut max_widths = vec![0usize; num_columns];
for (idx, item) in self.items.iter().enumerate() {
let col = idx % num_columns;
let item_w = Self::item_width(item) + self.padding * 2;
max_widths[col] = max_widths[col].max(item_w);
}
if self.expand {
let content_total: usize = max_widths.iter().sum();
if content_total < available_width {
let extra = available_width - content_total;
let per_column = extra / num_columns;
let remainder = extra % num_columns;
for (i, width) in max_widths.iter_mut().enumerate() {
*width += per_column;
if i < remainder {
*width += 1;
}
}
}
}
let total: usize = max_widths.iter().sum();
if total > available_width {
max_widths = self.collapse_widths(&max_widths, available_width);
}
max_widths
}
}
fn collapse_widths(&self, widths: &[usize], available_width: usize) -> Vec<usize> {
let total: usize = widths.iter().sum();
if total <= available_width {
return widths.to_vec();
}
let mut result = widths.to_vec();
let excess = total - available_width;
let minimums = vec![0usize; widths.len()];
let shrinkable: Vec<usize> = result
.iter()
.zip(minimums.iter())
.map(|(w, m)| w.saturating_sub(*m))
.collect();
let total_shrinkable: usize = shrinkable.iter().sum();
if total_shrinkable == 0 {
return result;
}
for (i, shrink) in shrinkable.iter().enumerate() {
if *shrink > 0 {
let reduction = *shrink * excess / total_shrinkable;
result[i] = result[i].saturating_sub(reduction);
}
}
let new_total: usize = result.iter().sum();
if new_total > available_width {
let mut diff = new_total - available_width;
for i in (0..result.len()).rev() {
if diff == 0 {
break;
}
if result[i] > minimums[i] {
let can_remove = (result[i] - minimums[i]).min(diff);
result[i] -= can_remove;
diff -= can_remove;
}
}
}
result
}
fn auto_column_count(&self, total_width: usize) -> usize {
if self.items.is_empty() {
return 1;
}
let max_item_width = self
.items
.iter()
.map(|item| Self::item_width(item) + self.padding * 2)
.max()
.unwrap_or(1);
let min_column_width = max_item_width.max(1);
let mut columns = 1;
while columns < self.items.len() {
let next = columns + 1;
let needed_width = next * min_column_width + (next - 1) * self.gutter;
if needed_width > total_width {
break;
}
columns = next;
}
columns
}
#[must_use]
pub fn render(&self, total_width: usize) -> Vec<Vec<Segment<'a>>> {
if self.items.is_empty() {
return vec![];
}
let effective_width = match self.max_width {
Some(max) => total_width.min(max),
None => total_width,
};
let num_columns = self
.column_count
.unwrap_or_else(|| self.auto_column_count(effective_width));
let column_widths = self.calculate_column_widths(effective_width, num_columns);
if column_widths.is_empty() {
return vec![];
}
let num_rows = self.items.len().div_ceil(num_columns);
let mut result = Vec::with_capacity(num_rows);
for row_idx in 0..num_rows {
let mut row_segments = Vec::new();
#[expect(
clippy::needless_range_loop,
reason = "col_idx used for multiple purposes"
)]
for col_idx in 0..num_columns {
let item_idx = row_idx * num_columns + col_idx;
let column_width = column_widths[col_idx];
if col_idx > 0 && self.gutter > 0 {
row_segments.push(Segment::new(
" ".repeat(self.gutter),
Some(self.gutter_style.clone()),
));
}
if item_idx < self.items.len() {
let effective_padding = self.padding.min(column_width / 2);
if effective_padding > 0 {
row_segments.push(Segment::new(" ".repeat(effective_padding), None));
}
let content_width = column_width.saturating_sub(effective_padding * 2);
let mut content = self.items[item_idx].clone();
for seg in &mut content {
if seg.text.contains('\n') {
seg.text = std::borrow::Cow::Owned(seg.text.replace('\n', " "));
}
}
content =
crate::segment::adjust_line_length(content, content_width, None, false);
let aligned = Align::new(content, content_width)
.method(self.align)
.render();
row_segments.extend(aligned);
if effective_padding > 0 {
row_segments.push(Segment::new(" ".repeat(effective_padding), None));
}
} else {
row_segments.push(Segment::new(" ".repeat(column_width), None));
}
}
result.push(crate::segment::adjust_line_length(
row_segments,
effective_width,
None,
false,
));
}
result
}
#[must_use]
pub fn render_flat(&self, total_width: usize) -> Vec<Segment<'a>> {
let lines = self.render(total_width);
let mut result = Vec::new();
for (i, line) in lines.into_iter().enumerate() {
if i > 0 {
result.push(Segment::new("\n", None));
}
result.extend(line);
}
result
}
}
impl Renderable for Columns<'_> {
fn render<'b>(&'b self, _console: &Console, options: &ConsoleOptions) -> Vec<Segment<'b>> {
self.render_flat(options.max_width).into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_columns_new() {
let items = vec![vec![Segment::new("A", None)], vec![Segment::new("B", None)]];
let cols = Columns::new(items);
assert_eq!(cols.items.len(), 2);
}
#[test]
fn test_columns_from_strings() {
let cols = Columns::from_strings(&["A", "B", "C"]);
assert_eq!(cols.items.len(), 3);
}
#[test]
fn test_columns_builder() {
let cols = Columns::from_strings(&["A", "B"])
.column_count(2)
.gutter(4)
.expand(false)
.equal_width(true)
.align(AlignMethod::Center)
.padding(1);
assert_eq!(cols.column_count, Some(2));
assert_eq!(cols.gutter, 4);
assert!(!cols.expand);
assert!(cols.equal_width);
assert_eq!(cols.align, AlignMethod::Center);
assert_eq!(cols.padding, 1);
}
#[test]
fn test_columns_render_two_columns() {
let cols = Columns::from_strings(&["A", "B", "C", "D"])
.column_count(2)
.gutter(2)
.expand(false);
let lines = cols.render(20);
assert_eq!(lines.len(), 2);
}
#[test]
fn test_columns_render_three_columns() {
let cols = Columns::from_strings(&["A", "B", "C"])
.column_count(3)
.gutter(1);
let lines = cols.render(30);
assert_eq!(lines.len(), 1);
}
#[test]
fn test_columns_render_empty() {
let cols = Columns::new(vec![]);
let lines = cols.render(40);
assert!(lines.is_empty());
}
#[test]
fn test_columns_auto_count() {
let cols = Columns::from_strings(&["Hello", "World", "Test", "Here"]);
let auto_count = cols.auto_column_count(50);
assert!(auto_count >= 1);
}
#[test]
fn test_columns_equal_width() {
let cols = Columns::from_strings(&["Short", "Much Longer Item"])
.column_count(2)
.equal_width(true);
let widths = cols.calculate_column_widths(40, 2);
assert_eq!(widths[0], widths[1]);
}
#[test]
fn test_columns_with_gutter() {
let cols = Columns::from_strings(&["A", "B"]).column_count(2).gutter(4);
let lines = cols.render(20);
let line = &lines[0];
let text: String = line.iter().map(|s| s.text.as_ref()).collect();
assert!(text.contains(" ")); }
#[test]
fn test_columns_alignment() {
let cols = Columns::from_strings(&["Hi"])
.column_count(1)
.expand(true)
.equal_width(true)
.align(AlignMethod::Center);
let lines = cols.render(20);
let text: String = lines[0].iter().map(|s| s.text.as_ref()).collect();
assert!(text.starts_with(' ')); assert!(text.ends_with(' ')); }
#[test]
fn test_columns_render_flat() {
let cols = Columns::from_strings(&["A", "B", "C", "D"]).column_count(2);
let segments = cols.render_flat(20);
let has_newline = segments.iter().any(|s| s.text.contains('\n'));
assert!(has_newline);
}
#[test]
fn test_columns_padding_does_not_overflow_width() {
let cols = Columns::from_strings(&["A", "B"])
.column_count(2)
.gutter(1)
.padding(2);
let total_width = 4;
let lines = cols.render(total_width);
for line in lines {
let width: usize = line.iter().map(Segment::cell_length).sum();
assert!(
width <= total_width,
"line width {width} exceeds total_width {total_width}"
);
}
}
#[test]
fn test_columns_uneven_items() {
let cols = Columns::from_strings(&["1", "2", "3", "4", "5"]).column_count(2);
let lines = cols.render(20);
assert_eq!(lines.len(), 3);
}
#[test]
fn test_item_width() {
let item = vec![
Segment::new("Hello", None),
Segment::new(" ", None),
Segment::new("World", None),
];
assert_eq!(Columns::item_width(&item), 11);
}
#[test]
fn test_columns_single_column() {
let cols = Columns::from_strings(&["A", "B", "C"]).column_count(1);
let lines = cols.render(20);
assert_eq!(lines.len(), 3);
}
#[test]
fn test_columns_narrow_width() {
let cols = Columns::from_strings(&["Hello", "World"])
.column_count(2)
.gutter(2);
let total_width = 5;
let lines = cols.render(total_width);
assert!(!lines.is_empty());
for line in lines {
let width: usize = line.iter().map(Segment::cell_length).sum();
assert!(width <= total_width);
}
}
#[test]
fn test_columns_tiny_width_with_gutter_does_not_overflow() {
let cols = Columns::from_strings(&["A", "B"])
.column_count(2)
.gutter(3)
.equal_width(true)
.expand(false);
let total_width = 1;
let lines = cols.render(total_width);
assert_eq!(lines.len(), 1);
for line in lines {
let width: usize = line.iter().map(Segment::cell_length).sum();
assert!(
width <= total_width,
"line width {width} exceeds total_width {total_width}"
);
}
}
#[test]
fn test_columns_tiny_width_with_many_columns_does_not_overflow() {
let cols = Columns::from_strings(&["A", "B", "C"])
.column_count(3)
.gutter(2)
.equal_width(true);
let total_width = 2;
let lines = cols.render(total_width);
assert_eq!(lines.len(), 1);
for line in lines {
let width: usize = line.iter().map(Segment::cell_length).sum();
assert!(
width <= total_width,
"line width {width} exceeds total_width {total_width}"
);
}
}
#[test]
fn test_columns_zero_width() {
let cols = Columns::from_strings(&["A", "B"]);
let lines = cols.render(0);
assert_eq!(lines.len(), 2);
for line in &lines {
let width: usize = line.iter().map(Segment::cell_length).sum();
assert_eq!(width, 0);
}
}
#[test]
fn test_columns_many_items() {
let items: Vec<&str> = (0..20).map(|_| "X").collect();
let cols = Columns::from_strings(&items).column_count(4);
let lines = cols.render(40);
assert_eq!(lines.len(), 5);
}
#[test]
fn test_columns_wide_unicode() {
let items = vec![
vec![Segment::new("ä½ å¥½", None)], vec![Segment::new("世界", None)], ];
let cols = Columns::new(items).column_count(2).gutter(2);
let lines = cols.render(20);
assert_eq!(lines.len(), 1);
}
#[test]
fn test_columns_content_width_calculation() {
let cols = Columns::from_strings(&["Short", "Much Longer Item"])
.column_count(2)
.equal_width(false)
.expand(false);
let widths = cols.calculate_column_widths(40, 2);
assert!(widths.len() == 2);
}
#[test]
fn test_columns_expand_distribution() {
let cols = Columns::from_strings(&["A", "B"])
.column_count(2)
.gutter(2)
.expand(true);
let widths = cols.calculate_column_widths(20, 2);
let total: usize = widths.iter().sum();
assert!(total > 0);
}
#[test]
fn test_columns_right_align() {
let cols = Columns::from_strings(&["X"])
.column_count(1)
.expand(true)
.equal_width(true)
.align(AlignMethod::Right);
let lines = cols.render(20);
let text: String = lines[0].iter().map(|s| s.text.as_ref()).collect();
assert!(text.starts_with(' '));
}
#[test]
fn test_columns_padding_applied() {
let cols = Columns::from_strings(&["X"]).column_count(1).padding(2);
let lines = cols.render(20);
let text: String = lines[0].iter().map(|s| s.text.as_ref()).collect();
assert!(text.starts_with(" ")); }
#[test]
fn test_columns_max_width_limits_expansion() {
let cols = Columns::from_strings(&["A", "B", "C"])
.column_count(3)
.gutter(2)
.expand(true)
.max_width(60);
let lines = cols.render(400); let text: String = lines[0].iter().map(|s| s.text.as_ref()).collect();
assert!(
text.len() <= 60,
"Output width {} exceeds max_width 60",
text.len()
);
}
#[test]
fn test_columns_max_width_no_effect_on_narrow_terminal() {
let cols = Columns::from_strings(&["A", "B"])
.column_count(2)
.gutter(2)
.expand(true)
.max_width(100);
let lines = cols.render(40); let text: String = lines[0].iter().map(|s| s.text.as_ref()).collect();
assert!(
text.len() <= 40,
"Output width {} exceeds terminal width 40",
text.len()
);
}
#[test]
fn test_columns_at_400_width_without_max_causes_spread() {
let cols = Columns::from_strings(&["A", "B", "C"])
.column_count(3)
.gutter(4)
.expand(true);
let lines = cols.render(400);
let text: String = lines[0].iter().map(|s| s.text.as_ref()).collect();
let has_excessive_whitespace = text.contains(&" ".repeat(30));
assert!(
has_excessive_whitespace,
"Expected excessive whitespace without max_width constraint"
);
}
#[test]
fn test_columns_at_400_width_with_max_prevents_spread() {
let cols = Columns::from_strings(&["A", "B", "C"])
.column_count(3)
.gutter(4)
.expand(true)
.max_width(60);
let lines = cols.render(400);
let text: String = lines[0].iter().map(|s| s.text.as_ref()).collect();
let has_excessive_whitespace = text.contains(&" ".repeat(30));
assert!(
!has_excessive_whitespace,
"max_width should prevent excessive whitespace runs"
);
}
}