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    // Git only allocates `data->width` (per-column widest index) inside `shrink_columns`, i.e. the
256    // dense path. In non-dense mode `display_cell`'s `data->width` is NULL, so every cell is padded
257    // to the uniform `initial_width`. Mirror that: `width_idx` is `Some` only when dense.
258    let mut width_idx: Option<Vec<usize>> = None;
259
260    if colopts.dense() {
261        let mut wi: Vec<usize> = vec![0; cols];
262        compute_column_width(layout, n, &len, cols, rows, &mut wi);
263        while rows > 1 {
264            let prev_rows = rows;
265            let prev_cols = cols;
266            rows -= 1;
267            cols = div_round_up(n, rows);
268            if cols != prev_cols {
269                wi.resize(cols, 0);
270            }
271            compute_column_width(layout, n, &len, cols, rows, &mut wi);
272
273            let mut total = indent_len;
274            for x in 0..cols {
275                total += len[wi[x]];
276                total += opts.padding;
277            }
278            if total > width_budget {
279                rows = prev_rows;
280                cols = prev_cols;
281                wi.resize(cols, 0);
282                compute_column_width(layout, n, &len, cols, rows, &mut wi);
283                break;
284            }
285        }
286        width_idx = Some(wi);
287    }
288
289    let initial_width = len.iter().copied().max().unwrap_or(0) + opts.padding;
290    let spaces = vec![b' '; initial_width];
291
292    for y in 0..rows {
293        for x in 0..cols {
294            let i = xy_to_linear(layout, cols, rows, x, y);
295            if i >= n {
296                continue;
297            }
298
299            let cell_len = len[i];
300            let mut pad_len = cell_len;
301            if let Some(ref wi) = width_idx {
302                if len[wi[x]] < initial_width {
303                    pad_len += initial_width - len[wi[x]];
304                    pad_len = pad_len.saturating_sub(opts.padding);
305                }
306            }
307
308            let newline = match layout {
309                ColumnLayout::Column => i + rows >= n,
310                ColumnLayout::Row => x == cols - 1 || i == n - 1,
311                ColumnLayout::Plain => true,
312            };
313
314            if x == 0 {
315                write!(out, "{}", opts.indent)?;
316            }
317            write!(out, "{}", &list[i])?;
318            if newline {
319                write!(out, "{}", opts.nl)?;
320            } else {
321                let run = initial_width.saturating_sub(pad_len);
322                let run = run.min(spaces.len());
323                out.write_all(&spaces[..run])?;
324            }
325        }
326    }
327
328    Ok(())
329}
330
331/// Mark options as originating from the command line (`COL_PARSEOPT` + `COL_ENABLED`), then parse `arg`.
332pub fn apply_column_cli_arg(colopts: &mut ColOpts, arg: Option<&str>) -> Result<(), String> {
333    colopts.0 |= PARSEOPT;
334    colopts.0 &= !ENABLE_MASK;
335    colopts.0 |= ENABLED;
336    if let Some(a) = arg {
337        parse_column_tokens_into(a, colopts)?;
338    }
339    Ok(())
340}
341
342/// Read `column.status` and `column.ui` from config (Git `git_column_config` order).
343pub fn merge_column_config(
344    config: &crate::config::ConfigSet,
345    colopts: &mut ColOpts,
346) -> Result<(), String> {
347    if let Some(v) = config.get("column.status") {
348        parse_column_tokens_into(&v, colopts)?;
349    }
350    if let Some(v) = config.get("column.ui") {
351        parse_column_tokens_into(&v, colopts)?;
352    }
353    Ok(())
354}