Skip to main content

cargo_quality/differ/display/
grid.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use super::{formatting::pad_to_width, types::RenderedFile};
5
6/// Minimum space between columns in grid layout.
7pub const COLUMN_GAP: usize = 4;
8
9/// Minimum width for a file column to be considered viable.
10pub const MIN_FILE_WIDTH: usize = 40;
11
12/// Calculates optimal number of columns for grid layout.
13///
14/// Determines how many file columns can fit horizontally based on terminal
15/// width and file content widths. Uses a greedy algorithm that tries to
16/// maximize column count while respecting minimum width requirements.
17///
18/// # Algorithm
19///
20/// 1. Find maximum file width across all rendered files
21/// 2. Try column counts from N down to 1
22/// 3. Calculate total width: `cols × max_width + (cols-1) × gap`
23/// 4. Return first count that fits terminal width
24///
25/// # Arguments
26///
27/// * `files` - Slice of rendered files with calculated widths
28/// * `term_width` - Terminal width in characters
29///
30/// # Returns
31///
32/// Number of columns (1 to N) that optimally fit the terminal
33///
34/// # Performance
35///
36/// - O(n) to find max width
37/// - O(n) to try column counts (at most file count iterations)
38/// - Total: O(n) where n is file count
39///
40/// # Examples
41///
42/// ```
43/// use cargo_quality::differ::display::{grid::calculate_columns, types::RenderedFile};
44///
45/// let files = vec![
46///     RenderedFile {
47///         lines: Vec::new(),
48///         width: 50
49///     },
50///     RenderedFile {
51///         lines: Vec::new(),
52///         width: 45
53///     },
54/// ];
55///
56/// let cols = calculate_columns(&files, 150);
57/// assert!(cols >= 1 && cols <= 2);
58/// ```
59///
60/// ```
61/// use cargo_quality::differ::display::{grid::calculate_columns, types::RenderedFile};
62///
63/// let files = vec![RenderedFile {
64///     lines: Vec::new(),
65///     width: 100
66/// }];
67/// let cols = calculate_columns(&files, 80);
68/// assert_eq!(cols, 1); // Too wide for multiple columns
69/// ```
70#[inline]
71pub fn calculate_columns(files: &[RenderedFile], term_width: usize) -> usize {
72    if files.is_empty() {
73        return 1;
74    }
75
76    let max_file_width = files
77        .iter()
78        .map(|f| f.width)
79        .max()
80        .unwrap_or(MIN_FILE_WIDTH)
81        .max(MIN_FILE_WIDTH);
82
83    for cols in (1..=files.len()).rev() {
84        let total_width = cols * max_file_width + (cols.saturating_sub(1)) * COLUMN_GAP;
85
86        if total_width <= term_width {
87            return cols;
88        }
89    }
90
91    1
92}
93
94/// Renders files in responsive grid layout.
95///
96/// Arranges files horizontally in columns, printing them row by row. Each file
97/// occupies one column, with rows printed until all files are displayed.
98/// Handles variable line counts by padding short files with empty lines.
99///
100/// # Algorithm
101///
102/// 1. If single column: print each file vertically
103/// 2. Calculate column width (max file width)
104/// 3. Process files in chunks of `columns` size
105/// 4. For each chunk:
106///    - Find max line count
107///    - Print each row across all columns
108///    - Pad columns to align properly
109///
110/// # Arguments
111///
112/// * `files` - Slice of rendered files to display
113/// * `columns` - Number of columns to use (from `calculate_columns`)
114///
115/// # Performance
116///
117/// - Single allocation per output line
118/// - Pre-calculates maximum line count per chunk
119/// - Uses padding with pre-calculated widths
120///
121/// # Examples
122///
123/// ```no_run
124/// use cargo_quality::differ::display::{grid::render_grid, types::RenderedFile};
125///
126/// let file1 = RenderedFile {
127///     lines: vec!["Line 1".to_string(), "Line 2".to_string()],
128///     width: 40
129/// };
130///
131/// let file2 = RenderedFile {
132///     lines: vec!["Other 1".to_string(), "Other 2".to_string()],
133///     width: 40
134/// };
135///
136/// render_grid(&[file1, file2], 2);
137/// ```
138pub fn render_grid(files: &[RenderedFile], columns: usize) {
139    if files.is_empty() {
140        return;
141    }
142
143    if columns == 1 {
144        render_single_column(files);
145        return;
146    }
147
148    let col_width = files
149        .iter()
150        .map(|f| f.width)
151        .max()
152        .unwrap_or(MIN_FILE_WIDTH);
153
154    for chunk in files.chunks(columns) {
155        let max_lines = chunk.iter().map(|f| f.line_count()).max().unwrap_or(0);
156
157        for row_idx in 0..max_lines {
158            let mut row_output = String::with_capacity(columns * (col_width + COLUMN_GAP));
159
160            for (col_idx, file) in chunk.iter().enumerate() {
161                let line = file.lines.get(row_idx).map(String::as_str).unwrap_or("");
162
163                let padded = pad_to_width(line, col_width);
164                row_output.push_str(&padded);
165
166                if col_idx < chunk.len() - 1 {
167                    row_output.push_str(&" ".repeat(COLUMN_GAP));
168                }
169            }
170
171            println!("{}", row_output);
172        }
173
174        println!();
175    }
176}
177
178/// Renders files in single column mode.
179///
180/// Simple vertical layout for narrow terminals or when optimal layout requires
181/// one column. Prints each file sequentially with spacing.
182///
183/// # Arguments
184///
185/// * `files` - Slice of rendered files
186///
187/// # Performance
188///
189/// - Direct line-by-line output
190/// - No padding calculations needed
191/// - Minimal allocations
192#[inline]
193fn render_single_column(files: &[RenderedFile]) {
194    for file in files {
195        for line in &file.lines {
196            println!("{}", line);
197        }
198        println!();
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_calculate_columns_empty() {
208        let files: Vec<RenderedFile> = vec![];
209        let cols = calculate_columns(&files, 100);
210        assert_eq!(cols, 1);
211    }
212
213    #[test]
214    fn test_calculate_columns_single_narrow() {
215        let files = vec![RenderedFile {
216            lines: Vec::new(),
217            width: 40
218        }];
219        let cols = calculate_columns(&files, 200);
220        assert_eq!(cols, 1);
221    }
222
223    #[test]
224    fn test_calculate_columns_two_fit() {
225        let files = vec![
226            RenderedFile {
227                lines: Vec::new(),
228                width: 50
229            },
230            RenderedFile {
231                lines: Vec::new(),
232                width: 50
233            },
234        ];
235        let cols = calculate_columns(&files, 150);
236        assert!(cols >= 1);
237    }
238
239    #[test]
240    fn test_calculate_columns_narrow_terminal() {
241        let files = vec![
242            RenderedFile {
243                lines: Vec::new(),
244                width: 100
245            },
246            RenderedFile {
247                lines: Vec::new(),
248                width: 100
249            },
250        ];
251        let cols = calculate_columns(&files, 80);
252        assert_eq!(cols, 1);
253    }
254
255    #[test]
256    fn test_calculate_columns_wide_terminal() {
257        let files = vec![
258            RenderedFile {
259                lines: Vec::new(),
260                width: 40
261            },
262            RenderedFile {
263                lines: Vec::new(),
264                width: 40
265            },
266            RenderedFile {
267                lines: Vec::new(),
268                width: 40
269            },
270        ];
271        let cols = calculate_columns(&files, 250);
272        assert!(cols >= 2);
273    }
274
275    #[test]
276    fn test_render_grid_single_column() {
277        let file = RenderedFile {
278            lines: vec!["line1".to_string(), "line2".to_string()],
279            width: 40
280        };
281
282        render_grid(&[file], 1);
283    }
284
285    #[test]
286    fn test_render_grid_empty() {
287        let files: Vec<RenderedFile> = vec![];
288        render_grid(&files, 2);
289    }
290
291    #[test]
292    fn test_render_grid_multiple_columns() {
293        let file1 = RenderedFile {
294            lines: vec!["file1".to_string()],
295            width: 40
296        };
297
298        let file2 = RenderedFile {
299            lines: vec!["file2".to_string()],
300            width: 40
301        };
302
303        render_grid(&[file1, file2], 2);
304    }
305
306    #[test]
307    fn test_calculate_columns_respects_min_width() {
308        let files = vec![RenderedFile {
309            lines: Vec::new(),
310            width: 30
311        }];
312        let cols = calculate_columns(&files, 200);
313        assert_eq!(cols, 1);
314    }
315
316    #[test]
317    fn test_render_single_column_multiple_files() {
318        let file1 = RenderedFile {
319            lines: vec!["test1".to_string()],
320            width: 40
321        };
322
323        let file2 = RenderedFile {
324            lines: vec!["test2".to_string()],
325            width: 40
326        };
327
328        render_single_column(&[file1, file2]);
329    }
330}