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}