fast_rich/
columns.rs

1//! Multi-column layout for displaying content in columns.
2//!
3//! Similar to Rich's Columns for displaying lists in columns.
4
5use crate::console::RenderContext;
6use crate::renderable::{Renderable, Segment};
7use crate::style::Style;
8use crate::text::{Span, Text};
9use unicode_width::UnicodeWidthStr;
10
11/// Column layout mode.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum ColumnMode {
14    /// Equal width columns
15    #[default]
16    Equal,
17    /// Optimal width based on content
18    Fit,
19}
20
21/// A multi-column layout container.
22#[derive(Debug, Clone)]
23pub struct Columns {
24    /// Items to display
25    items: Vec<Text>,
26    /// Number of columns (0 = auto)
27    num_columns: usize,
28    /// Column mode
29    mode: ColumnMode,
30    /// Gap between columns
31    gap: usize,
32    /// Expand to full width
33    expand: bool,
34    /// Style for items
35    style: Style,
36}
37
38impl Columns {
39    /// Create a new Columns layout from items.
40    pub fn new<I, T>(items: I) -> Self
41    where
42        I: IntoIterator<Item = T>,
43        T: Into<Text>,
44    {
45        Columns {
46            items: items.into_iter().map(Into::into).collect(),
47            num_columns: 0,
48            mode: ColumnMode::Equal,
49            gap: 2,
50            expand: true,
51            style: Style::new(),
52        }
53    }
54
55    /// Set the number of columns (0 = auto).
56    pub fn num_columns(mut self, n: usize) -> Self {
57        self.num_columns = n;
58        self
59    }
60
61    /// Set the column mode.
62    pub fn mode(mut self, mode: ColumnMode) -> Self {
63        self.mode = mode;
64        self
65    }
66
67    /// Set the gap between columns.
68    pub fn gap(mut self, gap: usize) -> Self {
69        self.gap = gap;
70        self
71    }
72
73    /// Set whether to expand to full width.
74    pub fn expand(mut self, expand: bool) -> Self {
75        self.expand = expand;
76        self
77    }
78
79    /// Set the item style.
80    pub fn style(mut self, style: Style) -> Self {
81        self.style = style;
82        self
83    }
84
85    /// Calculate the number of columns based on content and width.
86    fn calculate_columns(&self, width: usize) -> usize {
87        if self.num_columns > 0 {
88            return self.num_columns;
89        }
90
91        if self.items.is_empty() {
92            return 1;
93        }
94
95        // Find max item width
96        let max_item_width = self
97            .items
98            .iter()
99            .map(|item| item.width())
100            .max()
101            .unwrap_or(1);
102
103        // Calculate how many columns fit
104        let min_col_width = max_item_width + self.gap;
105        let cols = (width + self.gap) / min_col_width.max(1);
106
107        cols.max(1).min(self.items.len())
108    }
109}
110
111impl Renderable for Columns {
112    fn render(&self, context: &RenderContext) -> Vec<Segment> {
113        if self.items.is_empty() {
114            return vec![];
115        }
116
117        let num_cols = self.calculate_columns(context.width);
118        let num_rows = self.items.len().div_ceil(num_cols);
119
120        // Calculate column widths
121        let total_gap = self.gap * (num_cols.saturating_sub(1));
122        let available = context.width.saturating_sub(total_gap);
123        let col_width = available / num_cols.max(1);
124
125        let mut segments = Vec::new();
126
127        for row_idx in 0..num_rows {
128            let mut row_spans = Vec::new();
129
130            for col_idx in 0..num_cols {
131                let item_idx = row_idx * num_cols + col_idx;
132
133                if col_idx > 0 {
134                    // Add gap
135                    row_spans.push(Span::raw(" ".repeat(self.gap)));
136                }
137
138                if item_idx < self.items.len() {
139                    let item = &self.items[item_idx];
140                    let content = item.plain_text();
141                    let content_width = UnicodeWidthStr::width(content.as_str());
142
143                    // Truncate or pad to column width
144                    let displayed = if content_width > col_width {
145                        truncate_to_width(&content, col_width)
146                    } else {
147                        let padding = col_width - content_width;
148                        format!("{}{}", content, " ".repeat(padding))
149                    };
150
151                    row_spans.push(Span::styled(displayed, self.style));
152                } else {
153                    // Empty cell
154                    row_spans.push(Span::raw(" ".repeat(col_width)));
155                }
156            }
157
158            segments.push(Segment::line(row_spans));
159        }
160
161        segments
162    }
163}
164
165fn truncate_to_width(s: &str, width: usize) -> String {
166    use unicode_segmentation::UnicodeSegmentation;
167
168    let mut result = String::new();
169    let mut current_width = 0;
170
171    for grapheme in s.graphemes(true) {
172        let grapheme_width = UnicodeWidthStr::width(grapheme);
173        if current_width + grapheme_width > width {
174            break;
175        }
176        result.push_str(grapheme);
177        current_width += grapheme_width;
178    }
179
180    // Pad to width
181    while current_width < width {
182        result.push(' ');
183        current_width += 1;
184    }
185
186    result
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_columns_basic() {
195        let items = vec!["a", "b", "c", "d", "e", "f"];
196        let columns = Columns::new(items).num_columns(3);
197
198        let context = RenderContext {
199            width: 30,
200            height: None,
201        };
202        let segments = columns.render(&context);
203
204        // Should have 2 rows (6 items / 3 cols)
205        assert_eq!(segments.len(), 2);
206    }
207
208    #[test]
209    fn test_columns_auto() {
210        let items = vec!["short", "items", "here"];
211        let columns = Columns::new(items);
212
213        let context = RenderContext {
214            width: 40,
215            height: None,
216        };
217        let segments = columns.render(&context);
218
219        // Should auto-calculate columns
220        assert!(!segments.is_empty());
221    }
222
223    #[test]
224    fn test_columns_empty() {
225        let columns = Columns::new(Vec::<&str>::new());
226        let context = RenderContext {
227            width: 40,
228            height: None,
229        };
230        let segments = columns.render(&context);
231        assert!(segments.is_empty());
232    }
233}