Skip to main content

vtcode_commons/
preview.rs

1//! Shared preview formatting helpers.
2
3use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
4
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6pub struct HeadTailPreview<'a, T> {
7    pub head: &'a [T],
8    pub tail: &'a [T],
9    pub hidden_count: usize,
10    pub total: usize,
11}
12
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct TextLineExcerpt<'a> {
15    pub head: Vec<&'a str>,
16    pub tail: Vec<&'a str>,
17    pub hidden_count: usize,
18    pub total: usize,
19}
20
21pub fn display_width(text: &str) -> usize {
22    UnicodeWidthStr::width(text)
23}
24
25pub fn truncate_to_display_width(text: &str, max_width: usize) -> &str {
26    if max_width == 0 {
27        return "";
28    }
29    if display_width(text) <= max_width {
30        return text;
31    }
32
33    let mut consumed_width = 0usize;
34    for (idx, ch) in text.char_indices() {
35        let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
36        if consumed_width + char_width > max_width {
37            return &text[..idx];
38        }
39        consumed_width += char_width;
40    }
41
42    text
43}
44
45pub fn truncate_with_ellipsis(text: &str, max_width: usize, ellipsis: &str) -> String {
46    if max_width == 0 {
47        return String::new();
48    }
49    if display_width(text) <= max_width {
50        return text.to_string();
51    }
52
53    let ellipsis_width = display_width(ellipsis);
54    if ellipsis_width >= max_width {
55        return truncate_to_display_width(ellipsis, max_width).to_string();
56    }
57
58    let truncated = truncate_to_display_width(text, max_width - ellipsis_width);
59    format!("{truncated}{ellipsis}")
60}
61
62pub fn pad_to_display_width(text: &str, width: usize, pad_char: char) -> String {
63    let current = display_width(text);
64    if current >= width {
65        return text.to_string();
66    }
67
68    let padding = pad_char.to_string().repeat(width - current);
69    format!("{text}{padding}")
70}
71
72pub fn suffix_for_display_width(value: &str, max_width: usize) -> &str {
73    if display_width(value) <= max_width {
74        return value;
75    }
76    if max_width == 0 {
77        return "";
78    }
79
80    let mut consumed_width = 0usize;
81    let mut start_idx = value.len();
82    for (idx, ch) in value.char_indices().rev() {
83        let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
84        if consumed_width + char_width > max_width {
85            break;
86        }
87        consumed_width += char_width;
88        start_idx = idx;
89    }
90
91    &value[start_idx..]
92}
93
94pub fn format_hidden_lines_summary(hidden: usize) -> String {
95    if hidden == 1 {
96        "… +1 line".to_string()
97    } else {
98        format!("… +{hidden} lines")
99    }
100}
101
102pub fn split_head_tail_preview<'a, T>(
103    items: &'a [T],
104    head: usize,
105    tail: usize,
106) -> HeadTailPreview<'a, T> {
107    let total = items.len();
108    if total <= head.saturating_add(tail) {
109        return HeadTailPreview {
110            head: items,
111            tail: &items[total..],
112            hidden_count: 0,
113            total,
114        };
115    }
116
117    let head_count = head.min(total);
118    let tail_count = tail.min(total.saturating_sub(head_count));
119    let hidden_count = total.saturating_sub(head_count + tail_count);
120
121    HeadTailPreview {
122        head: &items[..head_count],
123        tail: &items[total - tail_count..],
124        hidden_count,
125        total,
126    }
127}
128
129pub fn split_head_tail_preview_with_limit<'a, T>(
130    items: &'a [T],
131    limit: usize,
132    preferred_head: usize,
133) -> HeadTailPreview<'a, T> {
134    if limit == 0 {
135        return HeadTailPreview {
136            head: &items[..0],
137            tail: &items[..0],
138            hidden_count: items.len(),
139            total: items.len(),
140        };
141    }
142
143    if items.len() <= limit {
144        return HeadTailPreview {
145            head: items,
146            tail: &items[items.len()..],
147            hidden_count: 0,
148            total: items.len(),
149        };
150    }
151
152    let (head, tail) = summary_window(limit, preferred_head);
153    split_head_tail_preview(items, head, tail)
154}
155
156pub fn summary_window(limit: usize, preferred_head: usize) -> (usize, usize) {
157    if limit <= 2 {
158        return (0, limit);
159    }
160
161    let head = preferred_head.min((limit - 1) / 2).max(1);
162    let tail = limit.saturating_sub(head + 1).max(1);
163    (head, tail)
164}
165
166pub fn excerpt_text_lines<'a>(text: &'a str, head: usize, tail: usize) -> TextLineExcerpt<'a> {
167    let lines: Vec<&str> = text.lines().collect();
168    let total = lines.len();
169    if total <= head.saturating_add(tail) {
170        return TextLineExcerpt {
171            head: lines,
172            tail: Vec::new(),
173            hidden_count: 0,
174            total,
175        };
176    }
177
178    let head_count = head.min(total);
179    let tail_count = tail.min(total.saturating_sub(head_count));
180    let hidden_count = total.saturating_sub(head_count + tail_count);
181
182    TextLineExcerpt {
183        head: lines[..head_count].to_vec(),
184        tail: lines[total - tail_count..].to_vec(),
185        hidden_count,
186        total,
187    }
188}
189
190pub fn excerpt_text_lines_with_limit<'a>(
191    text: &'a str,
192    limit: usize,
193    preferred_head: usize,
194) -> TextLineExcerpt<'a> {
195    let lines: Vec<&str> = text.lines().collect();
196    let preview = split_head_tail_preview_with_limit(lines.as_slice(), limit, preferred_head);
197
198    TextLineExcerpt {
199        head: preview.head.to_vec(),
200        tail: preview.tail.to_vec(),
201        hidden_count: preview.hidden_count,
202        total: preview.total,
203    }
204}
205
206pub fn format_hidden_bytes_summary(hidden: usize) -> String {
207    format!("… [{hidden} bytes omitted] …")
208}
209
210pub fn condense_text_bytes(content: &str, head_bytes: usize, tail_bytes: usize) -> String {
211    let byte_len = content.len();
212    let max_inline = head_bytes + tail_bytes;
213    if byte_len <= max_inline {
214        return content.to_string();
215    }
216
217    let head_end = floor_char_boundary(content, head_bytes);
218    let tail_start_raw = byte_len.saturating_sub(tail_bytes);
219    let tail_start = ceil_char_boundary(content, tail_start_raw);
220
221    let omitted = byte_len
222        .saturating_sub(head_end)
223        .saturating_sub(byte_len - tail_start);
224
225    format!(
226        "{}\n\n{}\n\n{}",
227        &content[..head_end],
228        format_hidden_bytes_summary(omitted),
229        &content[tail_start..]
230    )
231}
232
233pub fn tail_preview_text(content: &str, tail_bytes: usize, max_lines: usize) -> String {
234    if content.is_empty() {
235        return String::new();
236    }
237
238    let tail_start = ceil_char_boundary(content, content.len().saturating_sub(tail_bytes));
239    let tail_slice = &content[tail_start..];
240
241    let mut line_start = 0usize;
242    if max_lines > 0 {
243        let mut seen = 0usize;
244        for (idx, b) in tail_slice.as_bytes().iter().enumerate().rev() {
245            if *b == b'\n' {
246                seen += 1;
247                if seen >= max_lines {
248                    line_start = idx.saturating_add(1);
249                    break;
250                }
251            }
252        }
253    }
254
255    let preview = &tail_slice[line_start..];
256    let omitted = tail_start.saturating_add(line_start);
257    if omitted == 0 {
258        return preview.to_string();
259    }
260
261    format!("{}\n{}", format_hidden_bytes_summary(omitted), preview)
262}
263
264fn floor_char_boundary(value: &str, index: usize) -> usize {
265    if index >= value.len() {
266        return value.len();
267    }
268
269    let mut i = index;
270    while i > 0 && !value.is_char_boundary(i) {
271        i -= 1;
272    }
273    i
274}
275
276fn ceil_char_boundary(value: &str, index: usize) -> usize {
277    if index >= value.len() {
278        return value.len();
279    }
280
281    let mut i = index;
282    while i < value.len() && !value.is_char_boundary(i) {
283        i += 1;
284    }
285    i
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn truncate_to_display_width_respects_wide_chars() {
294        let value = "表表表";
295        assert_eq!(truncate_to_display_width(value, 5), "表表");
296    }
297
298    #[test]
299    fn truncate_with_ellipsis_respects_width_budget() {
300        assert_eq!(truncate_with_ellipsis("abcdef", 4, "…"), "abc…");
301    }
302
303    #[test]
304    fn pad_to_display_width_handles_wide_chars() {
305        let padded = pad_to_display_width("表", 4, ' ');
306        assert_eq!(display_width(padded.as_str()), 4);
307    }
308
309    #[test]
310    fn suffix_for_display_width_preserves_tail() {
311        assert_eq!(suffix_for_display_width("hello/world.rs", 8), "world.rs");
312    }
313
314    #[test]
315    fn split_head_tail_preview_preserves_hidden_count() {
316        let items = [1, 2, 3, 4, 5, 6, 7];
317        let preview = split_head_tail_preview(&items, 2, 2);
318        assert_eq!(preview.head, &[1, 2]);
319        assert_eq!(preview.tail, &[6, 7]);
320        assert_eq!(preview.hidden_count, 3);
321        assert_eq!(preview.total, 7);
322    }
323
324    #[test]
325    fn split_head_tail_preview_keeps_short_input_intact() {
326        let items = [1, 2, 3];
327        let preview = split_head_tail_preview(&items, 2, 2);
328        assert_eq!(preview.head, &[1, 2, 3]);
329        assert!(preview.tail.is_empty());
330        assert_eq!(preview.hidden_count, 0);
331    }
332
333    #[test]
334    fn split_head_tail_preview_with_limit_preserves_total_and_gap() {
335        let items = [1, 2, 3, 4, 5, 6, 7];
336        let preview = split_head_tail_preview_with_limit(&items, 6, 3);
337        assert_eq!(preview.head, &[1, 2]);
338        assert_eq!(preview.tail, &[5, 6, 7]);
339        assert_eq!(preview.hidden_count, 2);
340        assert_eq!(preview.total, 7);
341    }
342
343    #[test]
344    fn summary_window_reserves_gap_row() {
345        assert_eq!(summary_window(6, 3), (2, 3));
346        assert_eq!(summary_window(2, 3), (0, 2));
347    }
348
349    #[test]
350    fn hidden_lines_summary_matches_existing_copy() {
351        assert_eq!(format_hidden_lines_summary(1), "… +1 line");
352        assert_eq!(format_hidden_lines_summary(4), "… +4 lines");
353    }
354
355    #[test]
356    fn excerpt_text_lines_builds_head_tail_vectors() {
357        let preview = excerpt_text_lines("l1\nl2\nl3\nl4\nl5\nl6", 2, 2);
358        assert_eq!(preview.head, vec!["l1", "l2"]);
359        assert_eq!(preview.tail, vec!["l5", "l6"]);
360        assert_eq!(preview.hidden_count, 2);
361        assert_eq!(preview.total, 6);
362    }
363
364    #[test]
365    fn condense_text_bytes_respects_utf8_boundaries() {
366        let mut content = "a".repeat(7);
367        content.push('é');
368        content.push_str("bbbbbbbb");
369
370        let preview = condense_text_bytes(&content, 8, 4);
371        assert!(preview.contains("bytes omitted"));
372        assert!(preview.is_char_boundary(0));
373    }
374
375    #[test]
376    fn tail_preview_text_keeps_last_lines_only() {
377        let input = (0..20)
378            .map(|index| format!("line-{index}"))
379            .collect::<Vec<_>>()
380            .join("\n");
381
382        let preview = tail_preview_text(&input, 40, 3);
383        assert!(preview.contains("bytes omitted"));
384        assert!(preview.contains("line-19"));
385        assert!(!preview.contains("line-1\n"));
386    }
387}