use crate::console::RenderContext;
use crate::renderable::{Renderable, Segment};
use crate::style::Style;
use crate::text::{Span, Text};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColumnMode {
#[default]
Equal,
Fit,
}
#[derive(Debug, Clone)]
pub struct Columns {
items: Vec<Text>,
num_columns: usize,
mode: ColumnMode,
gap: usize,
expand: bool,
style: Style,
}
impl Columns {
pub fn new<I, T>(items: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<Text>,
{
Columns {
items: items.into_iter().map(Into::into).collect(),
num_columns: 0,
mode: ColumnMode::Equal,
gap: 2,
expand: true,
style: Style::new(),
}
}
pub fn num_columns(mut self, n: usize) -> Self {
self.num_columns = n;
self
}
pub fn mode(mut self, mode: ColumnMode) -> Self {
self.mode = mode;
self
}
pub fn gap(mut self, gap: usize) -> Self {
self.gap = gap;
self
}
pub fn expand(mut self, expand: bool) -> Self {
self.expand = expand;
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
fn calculate_columns(&self, width: usize) -> usize {
if self.num_columns > 0 {
return self.num_columns;
}
if self.items.is_empty() {
return 1;
}
let max_item_width = self
.items
.iter()
.map(|item| item.width())
.max()
.unwrap_or(1);
let min_col_width = max_item_width + self.gap;
let cols = (width + self.gap) / min_col_width.max(1);
cols.max(1).min(self.items.len())
}
}
impl Renderable for Columns {
fn render(&self, context: &RenderContext) -> Vec<Segment> {
if self.items.is_empty() {
return vec![];
}
let num_cols = self.calculate_columns(context.width);
let num_rows = self.items.len().div_ceil(num_cols);
let total_gap = self.gap * (num_cols.saturating_sub(1));
let available = context.width.saturating_sub(total_gap);
let col_width = available / num_cols.max(1);
let mut segments = Vec::new();
for row_idx in 0..num_rows {
let mut row_spans = Vec::new();
for col_idx in 0..num_cols {
let item_idx = row_idx * num_cols + col_idx;
if col_idx > 0 {
row_spans.push(Span::raw(" ".repeat(self.gap)));
}
if item_idx < self.items.len() {
let item = &self.items[item_idx];
let content = item.plain_text();
let content_width = UnicodeWidthStr::width(content.as_str());
let displayed = if content_width > col_width {
truncate_to_width(&content, col_width)
} else {
let padding = col_width - content_width;
format!("{}{}", content, " ".repeat(padding))
};
row_spans.push(Span::styled(displayed, self.style));
} else {
row_spans.push(Span::raw(" ".repeat(col_width)));
}
}
segments.push(Segment::line(row_spans));
}
segments
}
}
fn truncate_to_width(s: &str, width: usize) -> String {
use unicode_segmentation::UnicodeSegmentation;
let mut result = String::new();
let mut current_width = 0;
for grapheme in s.graphemes(true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if current_width + grapheme_width > width {
break;
}
result.push_str(grapheme);
current_width += grapheme_width;
}
while current_width < width {
result.push(' ');
current_width += 1;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_columns_basic() {
let items = vec!["a", "b", "c", "d", "e", "f"];
let columns = Columns::new(items).num_columns(3);
let context = RenderContext {
width: 30,
height: None,
};
let segments = columns.render(&context);
assert_eq!(segments.len(), 2);
}
#[test]
fn test_columns_auto() {
let items = vec!["short", "items", "here"];
let columns = Columns::new(items);
let context = RenderContext {
width: 40,
height: None,
};
let segments = columns.render(&context);
assert!(!segments.is_empty());
}
#[test]
fn test_columns_empty() {
let columns = Columns::new(Vec::<&str>::new());
let context = RenderContext {
width: 40,
height: None,
};
let segments = columns.render(&context);
assert!(segments.is_empty());
}
}