1use crate::utils;
8
9use super::style::{CellStyle, Span};
10use unicode_segmentation::UnicodeSegmentation;
11
12pub 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
22pub 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
71pub 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
103pub 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
145pub 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
156pub 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
166pub 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
188pub 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
226pub fn spans_width(spans: &[Span]) -> usize {
228 spans.iter().map(|s| utils::text_width(&s.text)).sum()
229}
230
231fn 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}