Skip to main content

zellij_sheets/
layout.rs

1//! Layout engine for zellij-sheets.
2//!
3//! Implements a two-phase Pretext-inspired layout model:
4//!
5//! **Prepare phase** (`LayoutCache::prepare`) — run once on data load.
6//! Walks every cell and measures its display width using `unicode-width`,
7//! which handles CJK wide chars, emoji, and zero-width combiners correctly.
8//! Results are cached; no DOM, no re-measurement.
9//!
10//! **Layout phase** (`LayoutEngine::resolve`) — run on every render.
11//! Pure arithmetic against the cache. Given the current terminal width,
12//! negotiates column widths in two passes and returns a `Vec<ColumnLayout>`.
13//! Fast enough to run synchronously on every keypress or resize event.
14
15use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
16
17/// Width of the " | " separator between columns.
18const SEPARATOR_WIDTH: usize = 3;
19/// Absolute floor — columns never shrink below this.
20const DEFAULT_MIN_COL_WIDTH: usize = 4;
21/// Absolute ceiling — no single column hogs the viewport.
22const DEFAULT_MAX_COL_WIDTH: usize = 40;
23
24/// Measured properties of a single column.
25/// Built once during the prepare phase and stored in `LayoutCache`.
26#[derive(Debug, Clone)]
27pub struct ColumnMeasure {
28    /// Display width of the column header.
29    pub header_width: usize,
30    /// Widest cell value seen in this column.
31    pub max_content_width: usize,
32    /// Widest unbreakable token (longest single word).
33    /// Used as a soft floor during shrinking — we try not to break words.
34    pub min_content_width: usize,
35}
36
37/// Per-column measurements cached after a data load.
38/// Invalidated (replaced) whenever `SheetsState::init` is called.
39#[derive(Debug, Clone, Default)]
40pub struct LayoutCache {
41    pub columns: Vec<ColumnMeasure>,
42}
43
44impl LayoutCache {
45    /// Prepare phase: measure every cell in every column and cache the results.
46    ///
47    /// O(rows × cols) on load; O(1) per render afterward.
48    pub fn prepare(headers: &[String], rows: &[Vec<String>]) -> Self {
49        let col_count = headers.len();
50        let mut columns = Vec::with_capacity(col_count);
51
52        for (col, header) in headers.iter().enumerate() {
53            let header_width = UnicodeWidthStr::width(header.as_str());
54
55            let mut max_content_width: usize = 0;
56            let mut min_content_width: usize = 0;
57
58            for row in rows {
59                if let Some(cell) = row.get(col) {
60                    let cell_w = UnicodeWidthStr::width(cell.as_str());
61                    max_content_width = max_content_width.max(cell_w);
62
63                    let min_w = cell
64                        .split_whitespace()
65                        .map(UnicodeWidthStr::width)
66                        .max()
67                        .unwrap_or(0);
68                    min_content_width = min_content_width.max(min_w);
69                }
70            }
71
72            columns.push(ColumnMeasure {
73                header_width,
74                max_content_width,
75                min_content_width,
76            });
77        }
78
79        Self { columns }
80    }
81
82    pub fn is_empty(&self) -> bool {
83        self.columns.is_empty()
84    }
85
86    pub fn col_count(&self) -> usize {
87        self.columns.len()
88    }
89}
90
91/// Resolved layout for a single column — the output of the layout phase.
92#[derive(Debug, Clone)]
93pub struct ColumnLayout {
94    /// Zero-based column index.
95    pub index: usize,
96    /// Final resolved display width in terminal columns.
97    pub resolved_width: usize,
98    /// True when the column was shrunk below its ideal width.
99    pub truncated: bool,
100}
101
102/// Stateless layout engine. Instantiate once; call `resolve` on every render.
103pub struct LayoutEngine {
104    pub min_col_width: usize,
105    pub max_col_width: usize,
106}
107
108impl Default for LayoutEngine {
109    fn default() -> Self {
110        Self {
111            min_col_width: DEFAULT_MIN_COL_WIDTH,
112            max_col_width: DEFAULT_MAX_COL_WIDTH,
113        }
114    }
115}
116
117impl LayoutEngine {
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    pub fn with_bounds(min_col_width: usize, max_col_width: usize) -> Self {
123        Self {
124            min_col_width,
125            max_col_width,
126        }
127    }
128
129    /// Layout phase: resolve column widths for the given terminal width.
130    pub fn resolve(&self, cache: &LayoutCache, terminal_width: usize) -> Vec<ColumnLayout> {
131        let col_count = cache.col_count();
132        if col_count == 0 {
133            return Vec::new();
134        }
135
136        let separator_budget = SEPARATOR_WIDTH * col_count.saturating_sub(1);
137        let available = terminal_width.saturating_sub(separator_budget);
138
139        let mut widths: Vec<usize> = cache
140            .columns
141            .iter()
142            .map(|m| {
143                let ideal = m.header_width.max(m.max_content_width);
144                ideal.clamp(self.min_col_width, self.max_col_width)
145            })
146            .collect();
147
148        if widths.iter().sum::<usize>() > available {
149            self.shrink(&mut widths, available, cache);
150        }
151
152        widths
153            .iter()
154            .enumerate()
155            .map(|(i, &w)| {
156                let m = &cache.columns[i];
157                let ideal = m
158                    .header_width
159                    .max(m.max_content_width)
160                    .clamp(self.min_col_width, self.max_col_width);
161                ColumnLayout {
162                    index: i,
163                    resolved_width: w,
164                    truncated: w < ideal,
165                }
166            })
167            .collect()
168    }
169
170    /// Iteratively shed width from the widest shrinkable column.
171    fn shrink(&self, widths: &mut [usize], available: usize, cache: &LayoutCache) {
172        for use_soft_floor in [true, false] {
173            loop {
174                let total: usize = widths.iter().sum();
175                if total <= available {
176                    return;
177                }
178
179                let floor = |i: usize| -> usize {
180                    if use_soft_floor {
181                        cache.columns[i].min_content_width.max(self.min_col_width)
182                    } else {
183                        self.min_col_width
184                    }
185                };
186
187                let shrinkable: Vec<usize> = widths
188                    .iter()
189                    .enumerate()
190                    .filter(|&(i, &w)| w > floor(i))
191                    .map(|(i, _)| i)
192                    .collect();
193
194                if shrinkable.is_empty() {
195                    break;
196                }
197
198                let widest = shrinkable
199                    .iter()
200                    .copied()
201                    .max_by_key(|&i| widths[i])
202                    .unwrap();
203
204                let excess = total - available;
205                let room = widths[widest] - floor(widest);
206                widths[widest] -= room.min(excess);
207            }
208        }
209    }
210}
211
212/// Render a cell value into exactly `width` terminal columns.
213pub fn fit_cell(value: &str, width: usize) -> String {
214    if width == 0 {
215        return String::new();
216    }
217
218    let display_width = UnicodeWidthStr::width(value);
219    if display_width <= width {
220        let pad = width - display_width;
221        let mut s = value.to_string();
222        s.push_str(&" ".repeat(pad));
223        return s;
224    }
225
226    if width == 1 {
227        return "~".to_string();
228    }
229
230    let target = width - 1;
231    let mut out = String::new();
232    let mut used = 0;
233
234    for ch in value.chars() {
235        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
236        if used + ch_width > target {
237            break;
238        }
239        out.push(ch);
240        used += ch_width;
241    }
242
243    out.push('~');
244    out
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn make_headers() -> Vec<String> {
252        vec!["Name".into(), "Description".into(), "Count".into()]
253    }
254
255    fn make_rows() -> Vec<Vec<String>> {
256        vec![
257            vec!["apple".into(), "small red fruit".into(), "10".into()],
258            vec!["banana".into(), "long yellow fruit".into(), "200".into()],
259        ]
260    }
261
262    #[test]
263    fn fit_cell_pads_short_value() {
264        assert_eq!(fit_cell("abc", 5), "abc  ");
265    }
266
267    #[test]
268    fn fit_cell_exact_width() {
269        assert_eq!(fit_cell("abcd", 4), "abcd");
270    }
271
272    #[test]
273    fn fit_cell_truncates_long_value() {
274        assert_eq!(fit_cell("abcdef", 4), "abc~");
275    }
276
277    #[test]
278    fn fit_cell_handles_wide_chars() {
279        assert_eq!(fit_cell("表計算", 5), "表計~");
280    }
281
282    #[test]
283    fn fit_cell_zero_width() {
284        assert_eq!(fit_cell("abc", 0), "");
285    }
286
287    #[test]
288    fn fit_cell_empty_value() {
289        assert_eq!(fit_cell("", 3), "   ");
290    }
291
292    #[test]
293    fn cache_prepare_measures_headers() {
294        let cache = LayoutCache::prepare(&make_headers(), &[]);
295        assert_eq!(cache.columns[0].header_width, 4);
296        assert_eq!(cache.columns[1].header_width, 11);
297    }
298
299    #[test]
300    fn cache_prepare_measures_content() {
301        let cache = LayoutCache::prepare(&make_headers(), &make_rows());
302        assert_eq!(cache.columns[0].max_content_width, 6);
303        assert_eq!(cache.columns[1].max_content_width, 17);
304    }
305
306    #[test]
307    fn cache_prepare_min_content_width() {
308        let cache = LayoutCache::prepare(&make_headers(), &make_rows());
309        assert_eq!(cache.columns[1].min_content_width, 6);
310    }
311
312    #[test]
313    fn cache_is_empty_on_default() {
314        assert!(LayoutCache::default().is_empty());
315    }
316
317    #[test]
318    fn engine_resolve_empty_cache() {
319        let engine = LayoutEngine::new();
320        assert!(engine.resolve(&LayoutCache::default(), 80).is_empty());
321    }
322
323    #[test]
324    fn engine_resolve_fits_comfortably() {
325        let cache = LayoutCache::prepare(&make_headers(), &make_rows());
326        let engine = LayoutEngine::new();
327        let layouts = engine.resolve(&cache, 80);
328
329        assert_eq!(layouts.len(), 3);
330        assert!(layouts.iter().all(|layout| !layout.truncated));
331    }
332
333    #[test]
334    fn engine_resolve_shrinks_on_narrow_terminal() {
335        let cache = LayoutCache::prepare(&make_headers(), &make_rows());
336        let engine = LayoutEngine::new();
337        let layouts = engine.resolve(&cache, 20);
338
339        assert_eq!(layouts.len(), 3);
340        assert!(layouts.iter().any(|layout| layout.truncated));
341    }
342
343    #[test]
344    fn engine_never_shrinks_below_min() {
345        let cache = LayoutCache::prepare(&make_headers(), &make_rows());
346        let engine = LayoutEngine::with_bounds(4, 40);
347        let layouts = engine.resolve(&cache, 5);
348
349        assert!(layouts.iter().all(|layout| layout.resolved_width >= 4));
350    }
351
352    #[test]
353    fn engine_caps_at_max_col_width() {
354        let headers = vec!["Description".to_string()];
355        let rows = vec![vec!["x".repeat(120)]];
356        let cache = LayoutCache::prepare(&headers, &rows);
357        let engine = LayoutEngine::with_bounds(4, 20);
358        let layouts = engine.resolve(&cache, 80);
359
360        assert_eq!(layouts[0].resolved_width, 20);
361        assert!(!layouts[0].truncated);
362    }
363
364    #[test]
365    fn engine_resolve_indices_are_correct() {
366        let cache = LayoutCache::prepare(&make_headers(), &make_rows());
367        let engine = LayoutEngine::new();
368        let layouts = engine.resolve(&cache, 80);
369
370        assert_eq!(layouts[0].index, 0);
371        assert_eq!(layouts[1].index, 1);
372        assert_eq!(layouts[2].index, 2);
373    }
374}