Skip to main content

grit_lib/
git_column.rs

1//! Git-compatible column layout for long-format status (untracked / ignored lists).
2//!
3//! Mirrors the behaviour of upstream `column.c` / `print_columns` used by `wt-status.c`.
4
5use std::io::{self, Write};
6
7use unicode_width::UnicodeWidthStr;
8
9/// Layout mode (lower 4 bits of [`ColOpts`]).
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ColumnLayout {
12    /// Fill columns before rows (`COL_COLUMN`).
13    Column,
14    /// Fill rows before columns (`COL_ROW`).
15    Row,
16    /// One path per line (`COL_PLAIN`).
17    Plain,
18}
19
20/// Bit flags matching Git's `column.h`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct ColOpts(u32);
23
24const LAYOUT_MASK: u32 = 0x000F;
25const ENABLE_MASK: u32 = 0x0030;
26const PARSEOPT: u32 = 0x0040;
27const DENSE: u32 = 0x0080;
28
29const DISABLED: u32 = 0x0000;
30const ENABLED: u32 = 0x0010;
31const AUTO: u32 = 0x0020;
32
33const LAYOUT_COLUMN: u32 = 0;
34const LAYOUT_ROW: u32 = 1;
35const LAYOUT_PLAIN: u32 = 15;
36
37impl ColOpts {
38    /// Create an empty column option set.
39    #[must_use]
40    pub const fn new() -> Self {
41        Self(0)
42    }
43
44    fn layout_bits(self) -> u32 {
45        self.0 & LAYOUT_MASK
46    }
47
48    /// True when column layout is active (`COL_ENABLED`).
49    #[must_use]
50    pub fn is_active(self) -> bool {
51        self.0 & ENABLE_MASK == ENABLED
52    }
53
54    fn dense(self) -> bool {
55        self.0 & DENSE != 0
56    }
57
58    fn layout_mode(self) -> ColumnLayout {
59        match self.layout_bits() {
60            LAYOUT_ROW => ColumnLayout::Row,
61            LAYOUT_PLAIN => ColumnLayout::Plain,
62            _ => ColumnLayout::Column,
63        }
64    }
65}
66
67impl Default for ColOpts {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73/// Options passed to [`print_columns`], matching `struct column_options`.
74#[derive(Debug, Clone)]
75pub struct ColumnOptions {
76    /// Total width for layout. CLI callers should resolve terminal width before calling.
77    pub width: Option<usize>,
78    /// Padding between columns.
79    pub padding: usize,
80    /// Prefix to print before each output row.
81    pub indent: String,
82    /// Newline string appended at row boundaries.
83    pub nl: String,
84}
85
86impl Default for ColumnOptions {
87    fn default() -> Self {
88        Self {
89            width: None,
90            padding: 1,
91            indent: String::new(),
92            nl: "\n".to_owned(),
93        }
94    }
95}
96
97fn div_round_up(a: usize, b: usize) -> usize {
98    if b == 0 {
99        return a;
100    }
101    a.div_ceil(b)
102}
103
104fn item_width(s: &str) -> usize {
105    UnicodeWidthStr::width(s)
106}
107
108fn xy_to_linear(layout: ColumnLayout, cols: usize, rows: usize, x: usize, y: usize) -> usize {
109    match layout {
110        ColumnLayout::Column => x * rows + y,
111        ColumnLayout::Row => y * cols + x,
112        ColumnLayout::Plain => y,
113    }
114}
115
116/// Parse space- or comma-separated column tokens (Git `parse_config` / `parse_option`).
117pub fn parse_column_tokens_into(value: &str, colopts: &mut ColOpts) -> Result<(), String> {
118    let mut group_set: u8 = 0;
119    for raw in value.split([' ', ',']) {
120        let token = raw.trim();
121        if token.is_empty() {
122            continue;
123        }
124        parse_one_token(token, colopts, &mut group_set)?;
125    }
126    // Setting layout without enable/disable implies `always` (Git `parse_config`).
127    if group_set & 1 != 0 && group_set & 2 == 0 {
128        colopts.0 = (colopts.0 & !ENABLE_MASK) | ENABLED;
129    }
130    Ok(())
131}
132
133fn parse_one_token(token: &str, colopts: &mut ColOpts, group_set: &mut u8) -> Result<(), String> {
134    const LAYOUT_SET: u8 = 1;
135    const ENABLE_SET: u8 = 2;
136
137    let (neg_dense, name) = token
138        .strip_prefix("no")
139        .filter(|rest| rest.len() > 2)
140        .map(|rest| (true, rest))
141        .unwrap_or((false, token));
142
143    match name {
144        "always" => {
145            *group_set |= ENABLE_SET;
146            colopts.0 = (colopts.0 & !ENABLE_MASK) | ENABLED;
147        }
148        "never" => {
149            *group_set |= ENABLE_SET;
150            colopts.0 = (colopts.0 & !ENABLE_MASK) | DISABLED;
151        }
152        "auto" => {
153            *group_set |= ENABLE_SET;
154            colopts.0 = (colopts.0 & !ENABLE_MASK) | AUTO;
155        }
156        "plain" => {
157            *group_set |= LAYOUT_SET;
158            colopts.0 = (colopts.0 & !LAYOUT_MASK) | LAYOUT_PLAIN;
159        }
160        "column" => {
161            *group_set |= LAYOUT_SET;
162            colopts.0 = (colopts.0 & !LAYOUT_MASK) | LAYOUT_COLUMN;
163        }
164        "row" => {
165            *group_set |= LAYOUT_SET;
166            colopts.0 = (colopts.0 & !LAYOUT_MASK) | LAYOUT_ROW;
167        }
168        "dense" => {
169            if neg_dense {
170                colopts.0 &= !DENSE;
171            } else {
172                colopts.0 |= DENSE;
173            }
174        }
175        _ => return Err(format!("unsupported column option '{token}'")),
176    }
177    Ok(())
178}
179
180/// Apply `finalize_colopts` semantics: resolve `auto` using the supplied TTY state.
181pub fn finalize_colopts(colopts: &mut ColOpts, stdout_is_tty: bool) {
182    if colopts.0 & ENABLE_MASK != AUTO {
183        return;
184    }
185    colopts.0 &= !ENABLE_MASK;
186    if stdout_is_tty {
187        colopts.0 |= ENABLED;
188    }
189}
190
191fn compute_column_width(
192    layout: ColumnLayout,
193    list_len: usize,
194    len: &[usize],
195    cols: usize,
196    rows: usize,
197    width_idx: &mut [usize],
198) {
199    let n = list_len;
200    for x in 0..cols {
201        width_idx[x] = xy_to_linear(layout, cols, rows, x, 0);
202        for y in 0..rows {
203            let i = xy_to_linear(layout, cols, rows, x, y);
204            if i < n && len[width_idx[x]] < len[i] {
205                width_idx[x] = i;
206            }
207        }
208    }
209}
210
211/// Print `list` using Git column layout; when inactive, prints one `indent` + item + `nl` per row.
212pub fn print_columns(
213    out: &mut impl Write,
214    list: &[String],
215    colopts: ColOpts,
216    opts: &ColumnOptions,
217) -> io::Result<()> {
218    if list.is_empty() {
219        return Ok(());
220    }
221    if !colopts.is_active() {
222        for s in list {
223            write!(out, "{}{}{}", opts.indent, s, opts.nl)?;
224        }
225        return Ok(());
226    }
227
228    let layout = colopts.layout_mode();
229    if layout == ColumnLayout::Plain {
230        for s in list {
231            write!(out, "{}{}{}", opts.indent, s, opts.nl)?;
232        }
233        return Ok(());
234    }
235
236    let n = list.len();
237    let len: Vec<usize> = list.iter().map(|s| item_width(s)).collect();
238
239    // Git's fallback terminal width is 80, and `print_columns` uses one less.
240    let width_budget = opts.width.unwrap_or(79);
241    let indent_len = item_width(&opts.indent);
242
243    let mut cell_w = 0usize;
244    for &l in &len {
245        cell_w = cell_w.max(l);
246    }
247    cell_w += opts.padding;
248
249    let mut cols = (width_budget.saturating_sub(indent_len)) / cell_w;
250    if cols == 0 {
251        cols = 1;
252    }
253    let mut rows = div_round_up(n, cols);
254
255    let mut width_idx: Vec<usize> = vec![0; cols];
256    compute_column_width(layout, n, &len, cols, rows, &mut width_idx);
257
258    if colopts.dense() {
259        while rows > 1 {
260            let prev_rows = rows;
261            let prev_cols = cols;
262            rows -= 1;
263            cols = div_round_up(n, rows);
264            if cols != prev_cols {
265                width_idx.resize(cols, 0);
266            }
267            compute_column_width(layout, n, &len, cols, rows, &mut width_idx);
268
269            let mut total = indent_len;
270            for x in 0..cols {
271                total += len[width_idx[x]];
272                total += opts.padding;
273            }
274            if total > width_budget {
275                rows = prev_rows;
276                cols = prev_cols;
277                width_idx.resize(cols, 0);
278                compute_column_width(layout, n, &len, cols, rows, &mut width_idx);
279                break;
280            }
281        }
282    }
283
284    let initial_width = len.iter().copied().max().unwrap_or(0) + opts.padding;
285    let spaces = vec![b' '; initial_width];
286
287    for y in 0..rows {
288        for x in 0..cols {
289            let i = xy_to_linear(layout, cols, rows, x, y);
290            if i >= n {
291                continue;
292            }
293
294            let cell_len = len[i];
295            let mut pad_len = cell_len;
296            if len[width_idx[x]] < initial_width {
297                pad_len += initial_width - len[width_idx[x]];
298                pad_len = pad_len.saturating_sub(opts.padding);
299            }
300
301            let newline = match layout {
302                ColumnLayout::Column => i + rows >= n,
303                ColumnLayout::Row => x == cols - 1 || i == n - 1,
304                ColumnLayout::Plain => true,
305            };
306
307            if x == 0 {
308                write!(out, "{}", opts.indent)?;
309            }
310            write!(out, "{}", &list[i])?;
311            if newline {
312                write!(out, "{}", opts.nl)?;
313            } else {
314                let run = initial_width.saturating_sub(pad_len);
315                let run = run.min(spaces.len());
316                out.write_all(&spaces[..run])?;
317            }
318        }
319    }
320
321    Ok(())
322}
323
324/// Mark options as originating from the command line (`COL_PARSEOPT` + `COL_ENABLED`), then parse `arg`.
325pub fn apply_column_cli_arg(colopts: &mut ColOpts, arg: Option<&str>) -> Result<(), String> {
326    colopts.0 |= PARSEOPT;
327    colopts.0 &= !ENABLE_MASK;
328    colopts.0 |= ENABLED;
329    if let Some(a) = arg {
330        parse_column_tokens_into(a, colopts)?;
331    }
332    Ok(())
333}
334
335/// Read `column.status` and `column.ui` from config (Git `git_column_config` order).
336pub fn merge_column_config(
337    config: &crate::config::ConfigSet,
338    colopts: &mut ColOpts,
339) -> Result<(), String> {
340    if let Some(v) = config.get("column.status") {
341        parse_column_tokens_into(&v, colopts)?;
342    }
343    if let Some(v) = config.get("column.ui") {
344        parse_column_tokens_into(&v, colopts)?;
345    }
346    Ok(())
347}