Skip to main content

rab/tui/
visual_truncate.rs

1/// Shared utility for truncating text to visual lines (accounting for line wrapping).
2///
3/// Matches pi's `visual-truncate.ts`:
4/// - `truncateToVisualLines` — renders text as visual lines, returns last N
5///
6/// Used by both bash renderer and generic tool fallback for consistent behavior.
7use crate::tui::util::visible_width;
8
9/// Result of truncating to visual lines.
10pub struct VisualTruncateResult<'a> {
11    /// The selected logical lines to display.
12    pub lines: Vec<&'a str>,
13    /// Number of logical lines that were hidden (skipped).
14    pub skipped: usize,
15}
16
17/// Count how many visual (wrapped) lines a single logical line occupies
18/// at the given terminal width. Accounts for zero-width edge case.
19pub fn visual_line_count(line: &str, width: usize) -> usize {
20    if width == 0 {
21        return 1;
22    }
23    let vis = visible_width(line);
24    if vis == 0 {
25        return 1;
26    }
27    vis.div_ceil(width)
28}
29
30/// Select the last `max_visual_lines` visual lines from a list of logical lines.
31///
32/// Walks backwards from the end, counting how many visual rows each logical
33/// line occupies. Stops when the budget is exhausted or the first line is reached.
34///
35/// Returns `(selected_logical_lines, hidden_logical_line_count)`.
36///
37/// This matches pi's `truncateToVisualLines` which accounts for terminal wrapping.
38pub fn truncate_to_visual_lines<'a>(
39    lines: &'a [&'a str],
40    width: usize,
41    max_visual_lines: usize,
42) -> (Vec<&'a str>, usize) {
43    if lines.is_empty() || max_visual_lines == 0 {
44        return (vec![], 0);
45    }
46
47    // Compute visual line count per logical line
48    let visual_counts: Vec<usize> = lines.iter().map(|l| visual_line_count(l, width)).collect();
49
50    let total_visual: usize = visual_counts.iter().sum();
51
52    // Everything fits — no truncation
53    if total_visual <= max_visual_lines {
54        return (lines.to_vec(), 0);
55    }
56
57    // Walk backwards from the end, consuming visual lines
58    let mut budget = max_visual_lines;
59    let mut start = lines.len();
60
61    for (i, &vc) in visual_counts.iter().enumerate().rev() {
62        if vc > budget {
63            // This line alone exceeds the remaining budget — stop here.
64            // We can't split a logical line, so this line is excluded.
65            break;
66        }
67        budget -= vc;
68        start = i;
69    }
70
71    (lines[start..].to_vec(), start)
72}
73
74/// Convenience wrapper that also computes an info line about hidden lines.
75/// Returns `(preview_lines, hidden_count)`.
76/// Use `format_hidden_hint` to turn `hidden_count` into display text.
77pub fn truncate_preview<'a>(
78    lines: &'a [&'a str],
79    width: usize,
80    max_visual_lines: usize,
81) -> (Vec<&'a str>, usize) {
82    truncate_to_visual_lines(lines, width, max_visual_lines)
83}
84
85/// Format a hint about hidden lines for display (matching pi's format).
86/// Returns e.g. `"... (12 earlier lines, ctrl+o to expand)"`.
87pub fn format_hidden_hint(hidden: usize, expand_key: &str) -> String {
88    if expand_key.is_empty() {
89        format!("... {} earlier lines", hidden)
90    } else {
91        format!("... ({} earlier lines, {} to expand)", hidden, expand_key)
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn test_visual_line_count_ascii() {
101        assert_eq!(visual_line_count("hello", 80), 1);
102        assert_eq!(visual_line_count("", 80), 1);
103    }
104
105    #[test]
106    fn test_visual_line_count_wrapping() {
107        assert_eq!(visual_line_count(&"a".repeat(100), 80), 2);
108        assert_eq!(visual_line_count(&"a".repeat(160), 80), 2);
109        assert_eq!(visual_line_count(&"a".repeat(161), 80), 3);
110    }
111
112    #[test]
113    fn test_visual_line_count_zero_width() {
114        assert_eq!(visual_line_count("hello", 0), 1);
115    }
116
117    #[test]
118    fn test_truncate_to_visual_lines_no_truncation() {
119        let lines = vec!["short", "also short"];
120        let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 10);
121        assert_eq!(selected.len(), 2);
122        assert_eq!(hidden, 0);
123    }
124
125    #[test]
126    fn test_truncate_to_visual_lines_with_wrapping() {
127        let line1 = "a".repeat(100);
128        let line2 = "b".repeat(100);
129        let line3 = "c".repeat(100);
130        let lines = vec![line1.as_str(), line2.as_str(), line3.as_str()];
131
132        // 3 lines × 2 visual = 6 total. Request 4 → show last 2 logical lines.
133        let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 4);
134        assert_eq!(selected.len(), 2);
135        assert_eq!(hidden, 1);
136        assert_eq!(selected[0], line2.as_str());
137        assert_eq!(selected[1], line3.as_str());
138    }
139
140    #[test]
141    fn test_truncate_to_visual_lines_exact_fit() {
142        let line1 = "a".repeat(100);
143        let line2 = "b".repeat(100);
144        let lines = vec![line1.as_str(), line2.as_str()];
145        let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 4);
146        assert_eq!(selected.len(), 2);
147        assert_eq!(hidden, 0);
148    }
149
150    #[test]
151    fn test_truncate_to_visual_lines_empty() {
152        let lines: Vec<&str> = vec![];
153        let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 5);
154        assert!(selected.is_empty());
155        assert_eq!(hidden, 0);
156    }
157
158    #[test]
159    fn test_truncate_to_visual_lines_mixed_widths() {
160        let short1 = "short";
161        let long = "x".repeat(100);
162        let short2 = "also short";
163        let lines = vec![short1, long.as_str(), short2];
164
165        let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 3);
166        assert_eq!(selected.len(), 2);
167        assert_eq!(hidden, 1);
168        assert_eq!(selected[0], long.as_str());
169        assert_eq!(selected[1], short2);
170    }
171
172    #[test]
173    fn test_format_hidden_hint() {
174        let hint = format_hidden_hint(12, "C-O");
175        assert!(hint.contains("12"));
176        assert!(hint.contains("C-O"));
177
178        let hint = format_hidden_hint(5, "");
179        assert!(hint.contains("5"));
180        assert!(!hint.contains("to expand"));
181    }
182}