Skip to main content

thndrs_lib/cli/renderer/
layout.rs

1//! Width-aware layout helpers for the row model.
2//!
3//! These functions operate on the renderer's own [`Span`] and [`CellStyle`]
4//! types. They are the single source of truth for wrapping, padding, and
5//! truncation so that cursor placement and snapshots stay deterministic.
6
7use crate::utils;
8
9use super::style::{CellStyle, Span};
10use unicode_segmentation::UnicodeSegmentation;
11
12/// Maximum width usable for body content inside a padded block.
13///
14/// Uses two-cell left / two-cell right block padding used so
15/// transcript rows line up with the live region.
16pub fn content_width(max_width: usize) -> usize {
17    let left_pad = max_width.min(2);
18    let right_pad = max_width.saturating_sub(left_pad).min(2);
19    max_width.saturating_sub(left_pad + right_pad)
20}
21
22/// Word-wrap plain text into rows no wider than `width`.
23///
24/// Existing `\n` line breaks are preserved. Overlong words are hard-split.
25pub fn wrap_text(text: &str, width: usize) -> Vec<String> {
26    let width = width.max(1);
27    let mut rows = Vec::new();
28
29    for raw_line in text.split('\n') {
30        if raw_line.is_empty() {
31            rows.push(String::new());
32            continue;
33        }
34
35        let mut current = String::new();
36        let mut current_width = 0usize;
37        for word in raw_line.split_whitespace() {
38            let word_len = utils::text_width(word);
39
40            if current_width == 0 {
41                if word_len <= width {
42                    current.push_str(word);
43                    current_width = word_len;
44                } else {
45                    rows.extend(split_long_word(word, width));
46                }
47            } else if current_width + 1 + word_len <= width {
48                current.push(' ');
49                current.push_str(word);
50                current_width += 1 + word_len;
51            } else {
52                rows.push(std::mem::take(&mut current));
53                current_width = 0;
54                if word_len <= width {
55                    current.push_str(word);
56                    current_width = word_len;
57                } else {
58                    rows.extend(split_long_word(word, width));
59                }
60            }
61        }
62
63        if !current.is_empty() {
64            rows.push(current);
65        }
66    }
67
68    rows
69}
70
71/// Wrap plain text without normalizing whitespace.
72///
73/// Existing `\n` line breaks, indentation, and repeated spaces are preserved.
74/// Overlong lines are hard-split at grapheme boundaries.
75pub fn wrap_text_preserving_whitespace(text: &str, width: usize) -> Vec<String> {
76    let width = width.max(1);
77    let mut rows = Vec::new();
78
79    for raw_line in text.split('\n') {
80        if raw_line.is_empty() {
81            rows.push(String::new());
82            continue;
83        }
84
85        let mut current = String::new();
86        let mut current_width = 0usize;
87        for grapheme in raw_line.graphemes(true) {
88            let g_width = utils::grapheme_width(grapheme);
89            if current_width > 0 && current_width + g_width > width {
90                rows.push(std::mem::take(&mut current));
91                current_width = 0;
92            }
93            current.push_str(grapheme);
94            current_width += g_width;
95        }
96
97        rows.push(current);
98    }
99
100    rows
101}
102
103/// Wrap styled spans into rows no wider than `width`, preserving explicit
104/// `\n` newlines inside span text.
105///
106/// Spans are split at character boundaries; adjacent spans keep their
107/// individual styles. A single logical row can contain multiple spans. Blank
108/// (empty-text) input produces a single empty row so callers always get at
109/// least one row.
110pub fn wrap_spans(spans: &[Span], width: usize) -> Vec<Vec<Span>> {
111    let width = width.max(1);
112    let mut rows: Vec<Vec<Span>> = Vec::new();
113    let mut current: Vec<Span> = Vec::new();
114    let mut current_width = 0usize;
115
116    for span in spans {
117        for grapheme in span.text.graphemes(true) {
118            if grapheme.contains('\n') {
119                rows.push(std::mem::take(&mut current));
120                current_width = 0;
121                continue;
122            }
123            let g_width = utils::grapheme_width(grapheme);
124            if current_width > 0 && current_width + g_width > width {
125                rows.push(std::mem::take(&mut current));
126                current_width = 0;
127            }
128            if let Some(last) = current.last_mut()
129                && last.style == span.style
130            {
131                last.text.push_str(grapheme);
132            } else {
133                current.push(Span { text: grapheme.to_string(), style: span.style });
134            }
135            current_width += g_width;
136        }
137    }
138
139    if !current.is_empty() || rows.is_empty() {
140        rows.push(current);
141    }
142    rows
143}
144
145/// Left-pad a row's spans with `count` cells using `pad_style`.
146pub fn pad_left(spans: Vec<Span>, count: usize, pad_style: CellStyle) -> Vec<Span> {
147    if count == 0 {
148        return spans;
149    }
150    let mut out = Vec::with_capacity(spans.len() + 1);
151    out.push(Span::styled(" ".repeat(count), pad_style));
152    out.extend(spans);
153    out
154}
155
156/// Right-pad a row's spans with `count` cells using `pad_style`.
157pub fn pad_right(spans: Vec<Span>, count: usize, pad_style: CellStyle) -> Vec<Span> {
158    if count == 0 {
159        return spans;
160    }
161    let mut out = spans;
162    out.push(Span::styled(" ".repeat(count), pad_style));
163    out
164}
165
166/// Pad a row on both sides to reach exactly `width` columns.
167///
168/// Left pad is `min(2)`, right pad absorbs the remainder. Matches the
169/// renderer's transcript block padding so committed rows align with the live
170/// region.
171pub fn pad_row(spans: Vec<Span>, width: usize, pad_style: CellStyle) -> Vec<Span> {
172    if width == 0 {
173        return Vec::new();
174    }
175    let used = spans_width(&spans);
176    let left_pad = width.min(2);
177    let right_pad = width.saturating_sub(left_pad + used).min(2);
178    let mut out = pad_left(spans, left_pad, pad_style);
179    let body = width.saturating_sub(left_pad + right_pad);
180    let fill = body.saturating_sub(used);
181    if fill > 0 {
182        out.push(Span::styled(" ".repeat(fill), pad_style));
183    }
184    out = pad_right(out, right_pad, pad_style);
185    out
186}
187
188/// Truncate spans to `width` columns, appending `…` if anything was cut.
189///
190/// The ellipsis occupies one column, so the maximum retained content is
191/// `width - 1` when truncation occurs.
192pub fn truncate_spans(spans: &[Span], width: usize, ellipsis_style: CellStyle) -> Vec<Span> {
193    if width == 0 {
194        return Vec::new();
195    }
196    if spans_width(spans) <= width {
197        return spans.to_vec();
198    }
199
200    let keep_width = width.saturating_sub(1);
201    let mut out = Vec::new();
202    let mut used = 0usize;
203
204    for span in spans {
205        if used >= keep_width {
206            break;
207        }
208        let mut taken = String::new();
209        for grapheme in span.text.graphemes(true) {
210            let g_width = utils::grapheme_width(grapheme);
211            if used + g_width > keep_width {
212                break;
213            }
214            taken.push_str(grapheme);
215            used += g_width;
216        }
217        if !taken.is_empty() {
218            out.push(Span { text: taken, style: span.style });
219        }
220    }
221
222    out.push(Span::styled("…".to_string(), ellipsis_style));
223    out
224}
225
226/// Width (column count) of a span slice.
227pub fn spans_width(spans: &[Span]) -> usize {
228    spans.iter().map(|s| utils::text_width(&s.text)).sum()
229}
230
231/// Hard-split a single word into chunks no wider than `width`.
232fn split_long_word(word: &str, width: usize) -> Vec<String> {
233    let mut chunks = Vec::new();
234    let mut current = String::new();
235    let mut current_width = 0usize;
236    for grapheme in word.graphemes(true) {
237        let g_width = utils::grapheme_width(grapheme);
238        if current_width > 0 && current_width + g_width > width {
239            chunks.push(std::mem::take(&mut current));
240            current_width = 0;
241        }
242        current.push_str(grapheme);
243        current_width += g_width;
244    }
245    if !current.is_empty() {
246        chunks.push(current);
247    }
248    chunks
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::renderer::style::Color;
255
256    #[test]
257    fn content_width_subtracts_block_padding() {
258        assert_eq!(content_width(80), 76);
259        assert_eq!(content_width(4), 0);
260        assert_eq!(content_width(0), 0);
261        assert_eq!(content_width(6), 2);
262    }
263
264    #[test]
265    fn wrap_text_preserves_newlines() {
266        let rows = wrap_text("hello\nworld", 80);
267        assert_eq!(rows, vec!["hello", "world"]);
268    }
269
270    #[test]
271    fn wrap_text_empty_line_preserved() {
272        let rows = wrap_text("a\n\nb", 80);
273        assert_eq!(rows, vec!["a", "", "b"]);
274    }
275
276    #[test]
277    fn wrap_text_preserving_whitespace_keeps_indentation() {
278        let rows = wrap_text_preserving_whitespace("src/main.rs:2:    println!(\"hello\");", 80);
279        assert_eq!(rows, vec!["src/main.rs:2:    println!(\"hello\");"]);
280    }
281
282    #[test]
283    fn wrap_text_preserving_whitespace_keeps_repeated_spaces() {
284        let rows = wrap_text_preserving_whitespace("alpha    beta", 80);
285        assert_eq!(rows, vec!["alpha    beta"]);
286    }
287
288    #[test]
289    fn wrap_text_preserving_whitespace_hard_splits_lines() {
290        let rows = wrap_text_preserving_whitespace("  abcdef", 4);
291        assert_eq!(rows, vec!["  ab", "cdef"]);
292    }
293
294    #[test]
295    fn wrap_text_word_wraps() {
296        let rows = wrap_text("aa bb cc", 5);
297        assert_eq!(rows, vec!["aa bb", "cc"]);
298    }
299
300    #[test]
301    fn wrap_text_hard_splits_long_words() {
302        let rows = wrap_text("abcdef", 3);
303        assert_eq!(rows, vec!["abc", "def"]);
304    }
305
306    #[test]
307    fn wrap_text_uses_display_width() {
308        let rows = wrap_text("ab中cd", 4);
309        assert_eq!(rows, vec!["ab中", "cd"]);
310    }
311
312    #[test]
313    fn wrap_text_keeps_emoji_zwj_grapheme_together() {
314        let family = "👨\u{200d}👩\u{200d}👧";
315        let rows = wrap_text(&format!("a{family}b"), 3);
316        assert_eq!(rows, vec![format!("a{family}"), "b".to_string()]);
317    }
318
319    #[test]
320    fn wrap_spans_preserves_styles() {
321        let spans = vec![
322            Span::styled("red", CellStyle::new().fg(Color::Red)),
323            Span::styled("blue", CellStyle::new().fg(Color::Blue)),
324        ];
325        let rows = wrap_spans(&spans, 10);
326        assert_eq!(rows.len(), 1);
327        assert_eq!(rows[0].len(), 2);
328        assert_eq!(rows[0][0].text, "red");
329        assert_eq!(rows[0][1].text, "blue");
330    }
331
332    #[test]
333    fn wrap_spans_preserves_newlines() {
334        let spans = vec![Span::plain("ab\ncd")];
335        let rows = wrap_spans(&spans, 80);
336        assert_eq!(rows.len(), 2);
337        assert_eq!(rows[0][0].text, "ab");
338        assert_eq!(rows[1][0].text, "cd");
339    }
340
341    #[test]
342    fn wrap_spans_empty_input_produces_empty_row() {
343        let rows = wrap_spans(&[], 10);
344        assert_eq!(rows, vec![vec![]]);
345    }
346
347    #[test]
348    fn wrap_spans_splits_at_width() {
349        let spans = vec![Span::plain("abcdef")];
350        let rows = wrap_spans(&spans, 3);
351        assert_eq!(rows.len(), 2);
352        assert_eq!(rows[0][0].text, "abc");
353        assert_eq!(rows[1][0].text, "def");
354    }
355
356    #[test]
357    fn wrap_spans_uses_display_width() {
358        let spans = vec![Span::plain("a中b")];
359        let rows = wrap_spans(&spans, 3);
360        assert_eq!(rows.len(), 2);
361        assert_eq!(rows[0][0].text, "a中");
362        assert_eq!(rows[1][0].text, "b");
363    }
364
365    #[test]
366    fn wrap_spans_keeps_combining_mark_with_base() {
367        let spans = vec![Span::plain("ab\u{0327}")];
368        let rows = wrap_spans(&spans, 2);
369        assert_eq!(rows.len(), 1);
370        assert_eq!(rows[0][0].text, "ab\u{0327}");
371    }
372
373    #[test]
374    fn wrap_spans_keeps_emoji_zwj_grapheme_together() {
375        let family = "👨\u{200d}👩\u{200d}👧";
376        let spans = vec![Span::plain(format!("a{family}b"))];
377        let rows = wrap_spans(&spans, 3);
378        assert_eq!(rows.len(), 2);
379        assert_eq!(rows[0][0].text, format!("a{family}"));
380        assert_eq!(rows[1][0].text, "b");
381    }
382
383    #[test]
384    fn pad_row_adds_left_and_right_padding() {
385        let spans = vec![Span::plain("hi")];
386        let padded = pad_row(spans, 10, CellStyle::new());
387        assert_eq!(spans_width(&padded), 10);
388        assert_eq!(padded[0].text, "  ");
389        assert_eq!(padded.last().unwrap().text, "  ");
390    }
391
392    #[test]
393    fn pad_row_zero_width_returns_empty() {
394        let spans = vec![Span::plain("hi")];
395        let padded = pad_row(spans, 0, CellStyle::new());
396        assert!(padded.is_empty());
397    }
398
399    #[test]
400    fn truncate_spans_adds_ellipsis() {
401        let spans = vec![Span::plain("hello world")];
402        let out = truncate_spans(&spans, 8, CellStyle::default());
403        assert_eq!(spans_width(&out), 8);
404        assert!(out.last().unwrap().text.contains('…'));
405    }
406
407    #[test]
408    fn truncate_spans_short_unchanged() {
409        let spans = vec![Span::plain("hi")];
410        let out = truncate_spans(&spans, 10, CellStyle::default());
411        assert_eq!(spans_width(&out), 2);
412        assert_eq!(out[0].text, "hi");
413    }
414
415    #[test]
416    fn truncate_spans_zero_width_empty() {
417        let spans = vec![Span::plain("hi")];
418        let out = truncate_spans(&spans, 0, CellStyle::default());
419        assert!(out.is_empty());
420    }
421
422    #[test]
423    fn truncate_spans_keeps_emoji_zwj_grapheme_together() {
424        let family = "👨\u{200d}👩\u{200d}👧";
425        let spans = vec![Span::plain(format!("{family}abc"))];
426        let out = truncate_spans(&spans, 4, CellStyle::default());
427        assert_eq!(spans_width(&out), 4);
428        assert_eq!(out[0].text, format!("{family}a"));
429        assert_eq!(out.last().unwrap().text, "…");
430    }
431
432    #[test]
433    fn wrap_text_wraps_long_url() {
434        let url = "https://github.com/stormlight-labs/thndrs/blob/main/src/renderer/layout.rs";
435        let rows = wrap_text(url, 30);
436        assert!(rows.len() > 1, "long URL should wrap to multiple rows");
437        assert!(
438            rows.iter().all(|r| utils::text_width(r) <= 30),
439            "no wrapped row should exceed width 30"
440        );
441
442        let joined = rows.join("");
443        assert!(joined.contains("thndrs"), "URL content should survive wrapping");
444    }
445
446    #[test]
447    fn wrap_text_hard_splits_unbroken_url() {
448        let url = "https://example.com/very/long/path/that/exceeds/the/width";
449        let rows = wrap_text(url, 20);
450        assert!(rows.len() > 1, "long unbroken URL should hard-split");
451        assert!(
452            rows.iter().all(|r| utils::text_width(r) <= 20),
453            "no row should exceed width 20"
454        );
455    }
456
457    #[test]
458    fn wrap_text_wraps_prose_paragraph() {
459        let prose = "The renderer must keep display width and text boundaries separate. \
460                     Display width decides cell budgets and cursor columns. Grapheme \
461                     boundaries decide edit, wrap, truncate, and backend clipping steps.";
462        let rows = wrap_text(prose, 40);
463        assert!(rows.len() > 2, "prose paragraph should wrap to multiple rows");
464        assert!(
465            rows.iter().all(|r| utils::text_width(r) <= 40),
466            "no wrapped row should exceed width 40"
467        );
468        assert!(
469            rows[0].contains("renderer"),
470            "first row should start with the beginning of the prose"
471        );
472    }
473
474    #[test]
475    fn wrap_spans_wraps_mixed_styled_spans() {
476        let spans = vec![
477            Span::styled("error: ", CellStyle::new().fg(Color::Red).bold()),
478            Span::styled("mismatched types", CellStyle::new().fg(Color::Yellow)),
479            Span::plain(" in src/main.rs at line 42"),
480        ];
481        let rows = wrap_spans(&spans, 20);
482        assert!(rows.len() > 1, "mixed styled spans should wrap to multiple rows");
483        assert!(
484            rows.iter().all(|r| spans_width(r) <= 20),
485            "no wrapped row should exceed width 20"
486        );
487        assert!(rows[0][0].text.contains("error"));
488    }
489
490    #[test]
491    fn wrap_spans_preserves_style_boundaries_across_wrap() {
492        let spans = vec![
493            Span::styled("red-text-here", CellStyle::new().fg(Color::Red)),
494            Span::styled("blue-text-here", CellStyle::new().fg(Color::Blue)),
495        ];
496        let rows = wrap_spans(&spans, 14);
497        assert!(rows.len() > 1, "should wrap at width 14");
498        for row in &rows {
499            let styles: Vec<_> = row.iter().map(|s| s.style).collect();
500            for window in styles.windows(2) {
501                assert_ne!(window[0], window[1], "adjacent spans should have distinct styles");
502            }
503        }
504    }
505
506    #[test]
507    fn wrap_text_terminal_cell_clipping_cjk_at_boundary() {
508        let rows = wrap_text("ab中c", 3);
509        assert_eq!(rows, vec!["ab".to_string(), "中c".to_string()]);
510    }
511
512    #[test]
513    fn wrap_spans_clips_wide_grapheme_at_boundary() {
514        let flag = "🇺🇸";
515        let spans = vec![Span::plain(format!("a{flag}b"))];
516        let rows = wrap_spans(&spans, 2);
517        assert_eq!(rows.len(), 3);
518        assert_eq!(rows[0][0].text, "a");
519        assert_eq!(rows[1][0].text, flag);
520        assert_eq!(rows[2][0].text, "b");
521    }
522}