1use 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.saturating_sub(head_end).saturating_sub(byte_len - tail_start);
222
223 format!(
224 "{}\n\n{}\n\n{}",
225 &content[..head_end],
226 format_hidden_bytes_summary(omitted),
227 &content[tail_start..]
228 )
229}
230
231pub fn tail_preview_text(content: &str, tail_bytes: usize, max_lines: usize) -> String {
232 if content.is_empty() {
233 return String::new();
234 }
235
236 let tail_start = ceil_char_boundary(content, content.len().saturating_sub(tail_bytes));
237 let tail_slice = &content[tail_start..];
238
239 let mut line_start = 0usize;
240 if max_lines > 0 {
241 let mut seen = 0usize;
242 for (idx, b) in tail_slice.as_bytes().iter().enumerate().rev() {
243 if *b == b'\n' {
244 seen += 1;
245 if seen >= max_lines {
246 line_start = idx.saturating_add(1);
247 break;
248 }
249 }
250 }
251 }
252
253 let preview = &tail_slice[line_start..];
254 let omitted = tail_start.saturating_add(line_start);
255 if omitted == 0 {
256 return preview.to_string();
257 }
258
259 format!("{}\n{}", format_hidden_bytes_summary(omitted), preview)
260}
261
262fn floor_char_boundary(value: &str, index: usize) -> usize {
263 if index >= value.len() {
264 return value.len();
265 }
266
267 let mut i = index;
268 while i > 0 && !value.is_char_boundary(i) {
269 i -= 1;
270 }
271 i
272}
273
274fn ceil_char_boundary(value: &str, index: usize) -> usize {
275 if index >= value.len() {
276 return value.len();
277 }
278
279 let mut i = index;
280 while i < value.len() && !value.is_char_boundary(i) {
281 i += 1;
282 }
283 i
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn truncate_to_display_width_respects_wide_chars() {
292 let value = "表表表";
293 assert_eq!(truncate_to_display_width(value, 5), "表表");
294 }
295
296 #[test]
297 fn truncate_with_ellipsis_respects_width_budget() {
298 assert_eq!(truncate_with_ellipsis("abcdef", 4, "…"), "abc…");
299 }
300
301 #[test]
302 fn pad_to_display_width_handles_wide_chars() {
303 let padded = pad_to_display_width("表", 4, ' ');
304 assert_eq!(display_width(padded.as_str()), 4);
305 }
306
307 #[test]
308 fn suffix_for_display_width_preserves_tail() {
309 assert_eq!(suffix_for_display_width("hello/world.rs", 8), "world.rs");
310 }
311
312 #[test]
313 fn split_head_tail_preview_preserves_hidden_count() {
314 let items = [1, 2, 3, 4, 5, 6, 7];
315 let preview = split_head_tail_preview(&items, 2, 2);
316 assert_eq!(preview.head, &[1, 2]);
317 assert_eq!(preview.tail, &[6, 7]);
318 assert_eq!(preview.hidden_count, 3);
319 assert_eq!(preview.total, 7);
320 }
321
322 #[test]
323 fn split_head_tail_preview_keeps_short_input_intact() {
324 let items = [1, 2, 3];
325 let preview = split_head_tail_preview(&items, 2, 2);
326 assert_eq!(preview.head, &[1, 2, 3]);
327 assert!(preview.tail.is_empty());
328 assert_eq!(preview.hidden_count, 0);
329 }
330
331 #[test]
332 fn split_head_tail_preview_with_limit_preserves_total_and_gap() {
333 let items = [1, 2, 3, 4, 5, 6, 7];
334 let preview = split_head_tail_preview_with_limit(&items, 6, 3);
335 assert_eq!(preview.head, &[1, 2]);
336 assert_eq!(preview.tail, &[5, 6, 7]);
337 assert_eq!(preview.hidden_count, 2);
338 assert_eq!(preview.total, 7);
339 }
340
341 #[test]
342 fn summary_window_reserves_gap_row() {
343 assert_eq!(summary_window(6, 3), (2, 3));
344 assert_eq!(summary_window(2, 3), (0, 2));
345 }
346
347 #[test]
348 fn hidden_lines_summary_matches_existing_copy() {
349 assert_eq!(format_hidden_lines_summary(1), "… +1 line");
350 assert_eq!(format_hidden_lines_summary(4), "… +4 lines");
351 }
352
353 #[test]
354 fn excerpt_text_lines_builds_head_tail_vectors() {
355 let preview = excerpt_text_lines("l1\nl2\nl3\nl4\nl5\nl6", 2, 2);
356 assert_eq!(preview.head, vec!["l1", "l2"]);
357 assert_eq!(preview.tail, vec!["l5", "l6"]);
358 assert_eq!(preview.hidden_count, 2);
359 assert_eq!(preview.total, 6);
360 }
361
362 #[test]
363 fn condense_text_bytes_respects_utf8_boundaries() {
364 let mut content = "a".repeat(7);
365 content.push('é');
366 content.push_str("bbbbbbbb");
367
368 let preview = condense_text_bytes(&content, 8, 4);
369 assert!(preview.contains("bytes omitted"));
370 assert!(preview.is_char_boundary(0));
371 }
372
373 #[test]
374 fn tail_preview_text_keeps_last_lines_only() {
375 let input = (0..20).map(|index| format!("line-{index}")).collect::<Vec<_>>().join("\n");
376
377 let preview = tail_preview_text(&input, 40, 3);
378 assert!(preview.contains("bytes omitted"));
379 assert!(preview.contains("line-19"));
380 assert!(!preview.contains("line-1\n"));
381 }
382}