Skip to main content

f00_format/
columns.rs

1use f00_core::{Config, Entry, IndicatorStyle};
2use unicode_width::UnicodeWidthStr;
3
4use crate::color::Colorizer;
5use crate::human::block_display_with_unit;
6use crate::hyperlink::hyperlink_name;
7use crate::icons::icon_prefix;
8use crate::perms::classify_suffix;
9use crate::quoting::display_name;
10
11struct Prepared {
12    plain: String,
13    painted: String,
14    width: usize,
15}
16
17fn prepare_entries(entries: &[Entry], colorizer: &Colorizer, config: &Config) -> Vec<Prepared> {
18    let icons = config.icons;
19    let indicator = config.indicator_style();
20    let hide_ctrl = config.hide_control_chars();
21    let hyper = config.hyperlink_enabled();
22
23    entries
24        .iter()
25        .filter(|e| !e.is_dir_header)
26        .map(|e| {
27            let icon = icon_prefix(e, icons);
28            let suffix = classify_suffix(e, indicator);
29            let quoted = display_name(&e.name, config.quoting_style, hide_ctrl);
30            let mut plain = format!("{icon}{quoted}{suffix}");
31            if config.show_inode {
32                plain = format!("{} {plain}", e.inode);
33            }
34            if config.show_blocks {
35                let b = block_display_with_unit(e.blocks, config.blocks_unit());
36                plain = format!("{b} {plain}");
37            }
38            if config.show_context {
39                let ctx = if e.context.is_empty() {
40                    "?"
41                } else {
42                    e.context.as_str()
43                };
44                plain = format!("{ctx} {plain}");
45            }
46            let width = UnicodeWidthStr::width(plain.as_str());
47            let painted = colorizer.paint_name(e, &plain);
48            let painted = hyperlink_name(&e.path, &painted, hyper);
49            Prepared {
50                plain,
51                painted,
52                width,
53            }
54        })
55        .collect()
56}
57
58/// One entry per line.
59pub fn format_one_per_line(
60    entries: &[Entry],
61    colorizer: &Colorizer,
62    icons: bool,
63    indicator: IndicatorStyle,
64) -> String {
65    // Backward-compatible path without full Config.
66    let mut config = Config {
67        icons,
68        indicator,
69        classify: matches!(indicator, IndicatorStyle::Classify),
70        ..Config::default()
71    };
72    if matches!(indicator, IndicatorStyle::Classify) {
73        config.classify = true;
74    }
75    format_one_per_line_cfg(entries, colorizer, &config)
76}
77
78/// One entry per line with full config (quoting, zero, inode, etc.).
79pub fn format_one_per_line_cfg(
80    entries: &[Entry],
81    colorizer: &Colorizer,
82    config: &Config,
83) -> String {
84    let ending = config.line_ending();
85    let mut out = String::new();
86    let mut dired: Vec<(usize, usize)> = Vec::new();
87
88    let write_total = |out: &mut String, section: &[Entry]| {
89        // GNU `ls -s` prints `total N` for directory contents (even without `-l`).
90        if !config.show_blocks || config.zero || !config.emit_block_total {
91            return;
92        }
93        let total: u64 = section
94            .iter()
95            .filter(|e| !e.is_dir_header)
96            .map(|e| crate::human::block_display_with_unit(e.blocks, config.blocks_unit()))
97            .fold(0u64, u64::saturating_add);
98        out.push_str(&format!("total {total}"));
99        out.push_str(ending);
100    };
101
102    let write_entry = |out: &mut String, dired: &mut Vec<(usize, usize)>, entry: &Entry| {
103        let prepared = prepare_entries(std::slice::from_ref(entry), colorizer, config);
104        let p = &prepared[0];
105        if config.dired {
106            let start = out.len();
107            out.push_str(&p.painted);
108            let end = out.len();
109            dired.push((start, end));
110        } else {
111            out.push_str(&p.painted);
112        }
113        out.push_str(ending);
114    };
115
116    let has_headers = entries.iter().any(|e| e.is_dir_header);
117    if !has_headers {
118        write_total(&mut out, entries);
119        for entry in entries {
120            write_entry(&mut out, &mut dired, entry);
121        }
122    } else {
123        let mut i = 0;
124        while i < entries.len() {
125            if entries[i].is_dir_header {
126                if !out.is_empty() && !config.zero {
127                    out.push('\n');
128                }
129                out.push_str(&format!("{}:", entries[i].path.display()));
130                out.push_str(ending);
131                i += 1;
132            }
133            let start = i;
134            while i < entries.len() && !entries[i].is_dir_header {
135                i += 1;
136            }
137            let section = &entries[start..i];
138            write_total(&mut out, section);
139            for entry in section {
140                write_entry(&mut out, &mut dired, entry);
141            }
142        }
143    }
144
145    if config.dired && !config.zero {
146        out.push_str("//DIRED//");
147        for (s, e) in &dired {
148            out.push_str(&format!(" {s} {e}"));
149        }
150        out.push('\n');
151    }
152    out
153}
154
155/// Multi-column layout similar to `ls` default (column-major).
156pub fn format_columns(
157    entries: &[Entry],
158    colorizer: &Colorizer,
159    icons: bool,
160    indicator: IndicatorStyle,
161    terminal_width: usize,
162) -> String {
163    let mut config = Config {
164        icons,
165        indicator,
166        terminal_width,
167        classify: matches!(indicator, IndicatorStyle::Classify),
168        ..Config::default()
169    };
170    if matches!(indicator, IndicatorStyle::Classify) {
171        config.classify = true;
172    }
173    format_columns_cfg(entries, colorizer, &config, false)
174}
175
176/// Multi-column with full config. `across` selects row-major (`-x`).
177pub fn format_columns_cfg(
178    entries: &[Entry],
179    colorizer: &Colorizer,
180    config: &Config,
181    across: bool,
182) -> String {
183    let mut out = String::new();
184    let mut section: Vec<&Entry> = Vec::new();
185    let ending = config.line_ending();
186
187    let flush = |section: &mut Vec<&Entry>, out: &mut String| {
188        if section.is_empty() {
189            return;
190        }
191        let owned: Vec<Entry> = section.iter().map(|e| (*e).clone()).collect();
192        out.push_str(&format_columns_section(&owned, colorizer, config, across));
193        section.clear();
194    };
195
196    for entry in entries {
197        if entry.is_dir_header {
198            flush(&mut section, &mut out);
199            if !out.is_empty() && !config.zero {
200                out.push('\n');
201            }
202            out.push_str(&format!("{}:", entry.path.display()));
203            out.push_str(ending);
204        } else {
205            section.push(entry);
206        }
207    }
208    flush(&mut section, &mut out);
209    out
210}
211
212fn format_columns_section(
213    entries: &[Entry],
214    colorizer: &Colorizer,
215    config: &Config,
216    across: bool,
217) -> String {
218    let prepared = prepare_entries(entries, colorizer, config);
219    if prepared.is_empty() {
220        return String::new();
221    }
222
223    // Width 0 ⇒ unlimited (use a huge value).
224    let term_w = if config.terminal_width == 0 {
225        usize::MAX / 4
226    } else {
227        config.terminal_width.max(1)
228    };
229    let n = prepared.len();
230    let max_w = prepared.iter().map(|p| p.width).max().unwrap_or(1);
231    let col_gap = 2;
232    let ending = config.line_ending();
233
234    // With --zero, GNU falls back to one-per-line-ish; keep simple.
235    if config.zero {
236        let mut out = String::new();
237        for p in &prepared {
238            out.push_str(&p.painted);
239            out.push_str(ending);
240        }
241        return out;
242    }
243
244    let max_cols = ((term_w + col_gap) / (1 + col_gap)).max(1).min(n);
245
246    let mut best_cols = 1;
247    let mut best_rows = n;
248    let mut col_widths = vec![max_w];
249
250    for cols in (1..=max_cols).rev() {
251        let rows = n.div_ceil(cols);
252        let mut widths = vec![0usize; cols];
253        for (i, p) in prepared.iter().enumerate() {
254            let col = if across { i % cols } else { i / rows };
255            if col < cols {
256                widths[col] = widths[col].max(p.width);
257            }
258        }
259        let total: usize = widths.iter().sum::<usize>() + col_gap * cols.saturating_sub(1);
260        if total <= term_w {
261            best_cols = cols;
262            best_rows = rows;
263            col_widths = widths;
264            break;
265        }
266    }
267
268    let mut out = String::new();
269    for row in 0..best_rows {
270        for (col, col_width) in col_widths.iter().copied().enumerate().take(best_cols) {
271            let idx = if across {
272                row * best_cols + col
273            } else {
274                col * best_rows + row
275            };
276            if idx >= n {
277                continue;
278            }
279            let p = &prepared[idx];
280            out.push_str(&p.painted);
281            if col + 1 < best_cols {
282                let next_idx = if across {
283                    row * best_cols + col + 1
284                } else {
285                    (col + 1) * best_rows + row
286                };
287                if next_idx < n {
288                    let pad = col_width + col_gap - p.width;
289                    out.push_str(&" ".repeat(pad));
290                }
291            }
292        }
293        out.push_str(ending);
294    }
295    let _ = &prepared.iter().map(|p| &p.plain);
296    out
297}