1use std::cell::RefCell;
16use std::sync::Arc;
17
18use ratatui::style::Style;
19use ratatui::text::{Line, Span};
20
21use crate::wrap::{wrap_line, wrap_line_window, wrapped_row_count};
22
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
25pub enum WrapMode {
26 #[default]
29 Wrap,
30 Clip,
36}
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
43pub struct TextPos {
44 pub line: usize,
45 pub col: usize,
46}
47
48impl TextPos {
49 pub fn new(line: usize, col: usize) -> Self {
50 Self { line, col }
51 }
52}
53
54type LineStyles = Vec<Vec<(usize, usize, Style)>>;
58
59struct LineRows {
68 cum: Vec<u32>,
69 lens: Vec<usize>,
70}
71
72impl LineRows {
73 fn build(char_lens: impl Iterator<Item = usize>, width: usize, mode: WrapMode) -> Self {
74 let mut cum = vec![0u32];
75 let mut lens = Vec::new();
76 let mut total = 0u32;
77 for len in char_lens {
78 let rows = match mode {
79 WrapMode::Wrap => wrapped_row_count(len, width) as u32,
80 WrapMode::Clip => 1,
82 };
83 total += rows;
84 cum.push(total);
85 lens.push(len);
86 }
87 Self { cum, lens }
88 }
89
90 fn total_rows(&self) -> u32 {
91 (*self.cum.last().unwrap_or(&0)).max(1)
92 }
93
94 fn line_count(&self) -> usize {
95 self.cum.len().saturating_sub(1)
96 }
97
98 fn locate(&self, row: u32) -> (usize, u32) {
102 if self.cum.len() <= 1 {
103 return (0, 0);
104 }
105 let idx = self.cum.partition_point(|&c| c <= row);
108 let line = idx.saturating_sub(1).min(self.cum.len() - 2);
109 (line, row - self.cum[line])
110 }
111}
112
113pub struct PanelWrap {
116 raw: Arc<str>,
121 source: Arc<str>,
125 line_ranges: Vec<(usize, usize)>,
128 rows: LineRows,
129 width: usize,
130 mode: WrapMode,
131 line_styles: Option<LineStyles>,
136 last_window: RefCell<Option<(u16, u16, Vec<Line<'static>>)>>,
143}
144
145impl PanelWrap {
146 pub fn build(source: Arc<str>, width: usize) -> Self {
151 Self::build_with(source, width, WrapMode::Wrap)
152 }
153
154 pub fn build_with(source: Arc<str>, width: usize, mode: WrapMode) -> Self {
156 let line_ranges = Self::split_line_ranges(&source);
157 let rows = LineRows::build(
158 line_ranges
159 .iter()
160 .map(|&(s, e)| source[s..e].chars().count()),
161 width,
162 mode,
163 );
164 Self {
165 raw: Arc::clone(&source),
166 source,
167 line_ranges,
168 rows,
169 width,
170 mode,
171 line_styles: None,
172 last_window: RefCell::new(None),
173 }
174 }
175
176 fn split_line_ranges(source: &str) -> Vec<(usize, usize)> {
179 let mut line_ranges = Vec::new();
180 let bytes = source.as_bytes();
181 let mut start = 0usize;
182 for (i, &b) in bytes.iter().enumerate() {
183 if b == b'\n' {
184 let mut end = i;
185 if end > start && bytes[end - 1] == b'\r' {
186 end -= 1;
187 }
188 line_ranges.push((start, end));
189 start = i + 1;
190 }
191 }
192 if start < bytes.len() || line_ranges.is_empty() {
193 line_ranges.push((start, bytes.len()));
194 }
195 line_ranges
196 }
197
198 #[cfg(feature = "ansi")]
203 pub fn build_ansi(raw: Arc<str>, width: usize, mode: WrapMode) -> Self {
204 let (plain_lines, styles) = parse_ansi(&raw);
205 let source: Arc<str> = Arc::from(plain_lines.join("\n"));
206 let mut line_ranges = Vec::with_capacity(plain_lines.len().max(1));
209 let mut pos = 0usize;
210 for line in &plain_lines {
211 let start = pos;
212 let end = start + line.len();
213 line_ranges.push((start, end));
214 pos = end + 1; }
216 if line_ranges.is_empty() {
217 line_ranges.push((0, 0));
218 }
219 let rows = LineRows::build(plain_lines.iter().map(|l| l.chars().count()), width, mode);
220 Self {
221 raw,
222 source,
223 line_ranges,
224 rows,
225 width,
226 mode,
227 line_styles: Some(styles),
228 last_window: RefCell::new(None),
229 }
230 }
231
232 pub fn rebuild_if_needed(cache: &mut Option<PanelWrap>, source: &Arc<str>, width: usize) {
238 Self::rebuild_if_needed_with(cache, source, width, WrapMode::Wrap);
239 }
240
241 pub fn rebuild_if_needed_with(
245 cache: &mut Option<PanelWrap>,
246 source: &Arc<str>,
247 width: usize,
248 mode: WrapMode,
249 ) {
250 let stale = match cache {
251 Some(c) => {
252 !Arc::ptr_eq(&c.raw, source)
253 || c.width != width
254 || c.mode != mode
255 || c.line_styles.is_some()
256 }
257 None => true,
258 };
259 if stale {
260 *cache = Some(PanelWrap::build_with(Arc::clone(source), width, mode));
261 }
262 }
263
264 #[cfg(feature = "ansi")]
268 pub fn rebuild_if_needed_ansi(
269 cache: &mut Option<PanelWrap>,
270 raw: &Arc<str>,
271 width: usize,
272 mode: WrapMode,
273 ) {
274 let stale = match cache {
275 Some(c) => {
276 !Arc::ptr_eq(&c.raw, raw)
277 || c.width != width
278 || c.mode != mode
279 || c.line_styles.is_none()
280 }
281 None => true,
282 };
283 if stale {
284 *cache = Some(PanelWrap::build_ansi(Arc::clone(raw), width, mode));
285 }
286 }
287
288 pub fn mode(&self) -> WrapMode {
290 self.mode
291 }
292
293 pub fn line_count(&self) -> usize {
294 self.rows.line_count()
295 }
296
297 pub fn source(&self) -> &str {
301 &self.source
302 }
303
304 pub fn line_text(&self, idx: usize) -> &str {
305 let (s, e) = self.line_ranges[idx];
306 &self.source[s..e]
307 }
308
309 pub fn line_char_len(&self, idx: usize) -> usize {
310 self.rows.lens.get(idx).copied().unwrap_or(0)
311 }
312
313 pub fn total_rows(&self) -> u32 {
314 self.rows.total_rows()
315 }
316
317 pub fn visible_window(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
325 if height == 0 || self.line_count() == 0 {
326 return Vec::new();
327 }
328 if let Some((cached_scroll, cached_height, cached)) = self.last_window.borrow().as_ref()
329 && *cached_scroll == scroll
330 && *cached_height == height
331 {
332 return cached.clone();
333 }
334 let out = match self.mode {
335 WrapMode::Clip => self.visible_window_clip(scroll, height),
336 WrapMode::Wrap => self.visible_window_wrap(scroll, height),
337 };
338 *self.last_window.borrow_mut() = Some((scroll, height, out.clone()));
339 out
340 }
341
342 fn visible_window_wrap(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
344 let (start_line, row_in_line) = self.rows.locate(scroll as u32);
345 let height_usize = height as usize;
346 let mut out: Vec<Line<'static>> = Vec::with_capacity(height_usize);
347 let mut skip = row_in_line as usize;
348 for idx in start_line..self.line_count() {
349 if out.len() >= height_usize {
350 break;
351 }
352 let budget = height_usize - out.len();
353 if self.line_styles.is_none() {
354 out.extend(wrap_line_window(
355 self.line_text(idx),
356 self.width,
357 skip,
358 budget,
359 ));
360 } else {
361 out.extend(self.wrap_line_window_styled(idx, skip, budget));
362 }
363 skip = 0;
364 }
365 out.truncate(height_usize);
366 out
367 }
368
369 fn visible_window_clip(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
373 let start = scroll as usize;
374 let height_usize = height as usize;
375 let mut out: Vec<Line<'static>> = Vec::with_capacity(height_usize);
376 for idx in start..self.line_count() {
377 if out.len() >= height_usize {
378 break;
379 }
380 let end = self.line_char_len(idx).min(self.width);
381 out.push(Line::from(self.styled_spans(idx, 0, end)));
382 }
383 out
384 }
385
386 fn wrap_line_window_styled(
390 &self,
391 idx: usize,
392 skip_rows: usize,
393 max_rows: usize,
394 ) -> Vec<Line<'static>> {
395 if max_rows == 0 {
396 return Vec::new();
397 }
398 if self.width == 0 {
399 return if skip_rows == 0 {
400 vec![Line::from(self.styled_spans(
401 idx,
402 0,
403 self.line_char_len(idx),
404 ))]
405 } else {
406 Vec::new()
407 };
408 }
409 let c0 = skip_rows.saturating_mul(self.width);
410 let c1 = c0.saturating_add(max_rows.saturating_mul(self.width));
411 let spans = self.styled_spans(idx, c0, c1);
412 if spans.is_empty() {
413 return Vec::new();
414 }
415 wrap_line(Line::from(spans), self.width)
416 }
417
418 fn styled_spans(&self, idx: usize, c0: usize, c1: usize) -> Vec<Span<'static>> {
422 if c1 <= c0 {
423 return Vec::new();
424 }
425 let text = self.line_text(idx);
426 let slice: String = text.chars().skip(c0).take(c1 - c0).collect();
427 if slice.is_empty() {
428 return Vec::new();
429 }
430 let runs = match &self.line_styles {
431 None => return vec![Span::raw(slice)],
432 Some(all) => all.get(idx).map(|v| v.as_slice()).unwrap_or(&[]),
433 };
434 if runs.is_empty() {
435 return vec![Span::raw(slice)];
436 }
437 let style_at = |abs: usize| {
438 runs.iter()
439 .find(|&&(s, e, _)| abs >= s && abs < e)
440 .map(|&(_, _, st)| st)
441 .unwrap_or_default()
442 };
443 let chars: Vec<char> = slice.chars().collect();
444 let mut spans = Vec::new();
445 let mut i = 0usize;
446 while i < chars.len() {
447 let style = style_at(c0 + i);
448 let mut j = i + 1;
449 while j < chars.len() && style_at(c0 + j) == style {
450 j += 1;
451 }
452 let seg: String = chars[i..j].iter().collect();
453 spans.push(Span::styled(seg, style));
454 i = j;
455 }
456 spans
457 }
458
459 pub fn textpos_to_row_col(&self, pos: TextPos) -> (u32, usize) {
464 if self.line_count() == 0 {
465 return (0, 0);
466 }
467 let line = pos.line.min(self.line_count() - 1);
468 let len = self.line_char_len(line);
469 let col = pos.col.min(len);
470 if self.mode == WrapMode::Clip || self.width == 0 {
473 return (self.rows.cum[line], col);
474 }
475 let rows_in_line = wrapped_row_count(len, self.width) as u32;
476 let row_in_line = ((col / self.width) as u32).min(rows_in_line.saturating_sub(1));
477 let col_in_row = col.saturating_sub(row_in_line as usize * self.width);
478 (self.rows.cum[line] + row_in_line, col_in_row)
479 }
480
481 pub fn row_col_to_textpos(&self, row: u32, col: usize) -> TextPos {
486 if self.line_count() == 0 {
487 return TextPos::new(0, 0);
488 }
489 let (line, row_in_line) = self.rows.locate(row);
490 let len = self.line_char_len(line);
491 let base = if self.width == 0 {
492 0
493 } else {
494 row_in_line as usize * self.width
495 };
496 TextPos::new(line, base.saturating_add(col).min(len))
502 }
503}
504
505#[cfg(feature = "ansi")]
509fn parse_ansi(raw: &str) -> (Vec<String>, LineStyles) {
510 use ansi_to_tui::IntoText;
511 use ratatui::text::Text;
512
513 let text = raw
514 .into_text()
515 .unwrap_or_else(|_| Text::raw(raw.to_string()));
516 let mut plain_lines: Vec<String> = Vec::with_capacity(text.lines.len().max(1));
517 let mut styles: LineStyles = Vec::with_capacity(text.lines.len().max(1));
518 for line in &text.lines {
519 let mut plain = String::new();
520 let mut runs: Vec<(usize, usize, Style)> = Vec::new();
521 let mut col = 0usize;
522 for span in &line.spans {
523 let content: &str = span.content.as_ref();
524 let n = content.chars().count();
525 if n == 0 {
526 continue;
527 }
528 runs.push((col, col + n, line.style.patch(span.style)));
529 plain.push_str(content);
530 col += n;
531 }
532 if plain.ends_with('\r') {
535 plain.pop();
536 let new_len = plain.chars().count();
537 if let Some(last) = runs.last_mut() {
538 last.1 = last.1.min(new_len);
539 if last.0 >= last.1 {
540 runs.pop();
541 }
542 }
543 }
544 plain_lines.push(plain);
545 styles.push(runs);
546 }
547 if plain_lines.is_empty() {
548 plain_lines.push(String::new());
549 styles.push(Vec::new());
550 }
551 (plain_lines, styles)
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557
558 fn wrap(text: &str, width: usize) -> PanelWrap {
559 PanelWrap::build(Arc::from(text), width)
560 }
561
562 fn clip(text: &str, width: usize) -> PanelWrap {
563 PanelWrap::build_with(Arc::from(text), width, WrapMode::Clip)
564 }
565
566 fn row_text(line: &Line<'static>) -> String {
567 line.spans.iter().map(|s| s.content.as_ref()).collect()
568 }
569
570 #[test]
571 fn clip_mode_maps_one_row_per_line_regardless_of_length() {
572 let w = clip("0123456789ABCDE\nshort", 10);
574 assert_eq!(w.line_count(), 2);
575 assert_eq!(w.total_rows(), 2, "one row per raw line, no wrapping");
576 let rows = w.visible_window(0, 5);
579 assert_eq!(rows.len(), 2);
580 assert_eq!(row_text(&rows[0]), "0123456789", "clipped to width");
581 assert_eq!(row_text(&rows[1]), "short");
582 }
583
584 #[test]
585 fn clip_mode_row_and_textpos_map_straight_through() {
586 let w = clip("0123456789ABCDE\nsecond", 10);
587 assert_eq!(w.textpos_to_row_col(TextPos::new(1, 3)), (1, 3));
589 assert_eq!(w.row_col_to_textpos(1, 3), TextPos::new(1, 3));
590 assert_eq!(w.row_col_to_textpos(0, 4), TextPos::new(0, 4));
592 }
593
594 #[test]
595 fn clip_mode_scrolls_by_whole_lines() {
596 let body: String = (0..1000).map(|i| format!("line {i}\n")).collect();
597 let w = clip(&body, 4); let rows = w.visible_window(500, 3);
599 assert_eq!(rows.len(), 3);
600 assert_eq!(row_text(&rows[0]), "line");
601 assert_eq!(w.total_rows(), 1000);
603 }
604
605 #[test]
606 fn splits_lines_like_str_lines_including_trailing_newline_and_crlf() {
607 let w = wrap("a\r\nb\nc", 10);
608 assert_eq!(w.line_count(), 3);
609 assert_eq!(w.line_text(0), "a");
610 assert_eq!(w.line_text(1), "b");
611 assert_eq!(w.line_text(2), "c");
612
613 let w2 = wrap("a\nb\n", 10);
614 assert_eq!(
615 w2.line_count(),
616 2,
617 "no trailing empty line after a final \\n, matching str::lines()"
618 );
619 }
620
621 #[test]
622 fn empty_body_has_one_line_and_one_row() {
623 let w = wrap("", 10);
624 assert_eq!(w.line_count(), 1);
625 assert_eq!(w.total_rows(), 1);
626 }
627
628 #[test]
629 fn total_rows_accounts_for_wrapping_long_lines() {
630 let w = wrap("0123456789ABCDE\n", 10);
632 assert_eq!(w.total_rows(), 2);
633 }
634
635 #[test]
636 fn row_col_and_textpos_roundtrip_for_a_wrapped_line() {
637 let w = wrap("0123456789ABCDE", 10); assert_eq!(w.row_col_to_textpos(0, 3), TextPos::new(0, 3));
639 assert_eq!(w.row_col_to_textpos(1, 2), TextPos::new(0, 12));
640 assert_eq!(w.textpos_to_row_col(TextPos::new(0, 3)), (0, 3));
641 assert_eq!(w.textpos_to_row_col(TextPos::new(0, 12)), (1, 2));
642 assert_eq!(w.textpos_to_row_col(TextPos::new(0, 15)), (1, 5));
644 }
645
646 #[test]
647 fn locate_binary_search_finds_the_right_line_for_a_huge_body() {
648 let body: String = (0..100_000).map(|i| format!("line {i}\n")).collect();
649 let w = wrap(&body, 20);
650 assert_eq!(w.row_col_to_textpos(50_000, 0), TextPos::new(50_000, 0));
653 }
654
655 #[test]
656 fn visible_window_only_wraps_the_requested_rows() {
657 let body: String = (0..1000).map(|i| format!("line {i}\n")).collect();
658 let w = wrap(&body, 20);
659 let rows = w.visible_window(500, 5);
660 assert_eq!(rows.len(), 5);
661 let text: Vec<String> = rows
662 .iter()
663 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
664 .collect();
665 assert_eq!(
666 text,
667 vec!["line 500", "line 501", "line 502", "line 503", "line 504"]
668 );
669 }
670
671 #[test]
678 fn visible_window_is_correct_for_a_single_enormous_unbroken_line() {
679 let body: String = "abcdefghij".repeat(200_000); let w = wrap(&body, 10);
681
682 let top = w.visible_window(0, 3);
683 assert_eq!(top.len(), 3);
684 let row0: String = top[0].spans.iter().map(|s| s.content.as_ref()).collect();
685 assert_eq!(row0, "abcdefghij", "row 0 is chars [0, 10)");
686 let row2: String = top[2].spans.iter().map(|s| s.content.as_ref()).collect();
687 assert_eq!(
688 row2, "abcdefghij",
689 "row 2 (chars [20, 30)) lands mid-repeat but still aligned"
690 );
691
692 let mid = w.visible_window(50_000, 2);
694 assert_eq!(mid.len(), 2);
695 let mid_row: String = mid[0].spans.iter().map(|s| s.content.as_ref()).collect();
696 assert_eq!(mid_row, "abcdefghij");
697
698 let again = w.visible_window(50_000, 2);
701 let again_text: Vec<String> = again
702 .iter()
703 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
704 .collect();
705 let mid_text: Vec<String> = mid
706 .iter()
707 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
708 .collect();
709 assert_eq!(again_text, mid_text);
710 }
711
712 #[test]
722 fn visible_window_stays_fast_across_many_redraws_of_a_single_huge_line() {
723 use std::time::{Duration, Instant};
724 let body: String = "x".repeat(5_000_000);
725 let w = wrap(&body, 78);
726
727 let start = Instant::now();
728 for _ in 0..200 {
729 let rows = w.visible_window(0, 30);
730 assert_eq!(
731 rows.len(),
732 30,
733 "the first 30 wrapped rows of a 5,000,000-char line at width 78"
734 );
735 }
736 let elapsed = start.elapsed();
737 assert!(
738 elapsed < Duration::from_secs(2),
739 "200 redraws of a single 5MB line took {elapsed:?} — expected a small fraction of a second"
740 );
741 }
742
743 #[test]
744 fn rebuild_if_needed_skips_rebuilding_on_an_unchanged_pointer_and_width() {
745 let source: Arc<str> = Arc::from("hello\nworld");
746 let mut cache: Option<PanelWrap> = None;
747 PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
748 let first_ptr = cache.as_ref().unwrap().source.as_ptr();
749 PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
751 assert_eq!(cache.as_ref().unwrap().source.as_ptr(), first_ptr);
752 PanelWrap::rebuild_if_needed(&mut cache, &source, 20);
754 assert_eq!(cache.as_ref().unwrap().width, 20);
755 let source2: Arc<str> = Arc::from("hello\nworld");
758 PanelWrap::rebuild_if_needed(&mut cache, &source2, 20);
759 assert!(Arc::ptr_eq(&cache.as_ref().unwrap().source, &source2));
760 }
761}
762
763#[cfg(all(test, feature = "ansi"))]
764mod ansi_tests {
765 use super::*;
766 use ratatui::style::Color;
767
768 fn row_text(line: &Line<'static>) -> String {
769 line.spans.iter().map(|s| s.content.as_ref()).collect()
770 }
771
772 const RED_THEN_PLAIN: &str = "\x1b[31mred\x1b[0m plain";
773
774 #[test]
775 fn geometry_and_copy_use_the_stripped_text() {
776 let w = PanelWrap::build_ansi(Arc::from(RED_THEN_PLAIN), 40, WrapMode::Wrap);
777 assert_eq!(w.line_count(), 1);
779 assert_eq!(w.line_text(0), "red plain");
780 assert_eq!(w.line_char_len(0), 9);
781 }
782
783 #[test]
784 fn rendered_rows_keep_their_colour() {
785 let w = PanelWrap::build_ansi(Arc::from(RED_THEN_PLAIN), 40, WrapMode::Wrap);
786 let rows = w.visible_window(0, 1);
787 assert_eq!(rows.len(), 1);
788 assert_eq!(row_text(&rows[0]), "red plain");
789 assert_eq!(rows[0].spans[0].content.as_ref(), "red");
791 assert_eq!(rows[0].spans[0].style.fg, Some(Color::Red));
792 let plain: String = rows[0].spans[1..]
793 .iter()
794 .map(|s| s.content.as_ref())
795 .collect();
796 assert_eq!(plain, " plain");
797 assert_ne!(
798 rows[0].spans[1].style.fg,
799 Some(Color::Red),
800 "the reset run is not red"
801 );
802 }
803
804 #[test]
805 fn colour_survives_wrapping_across_a_row_boundary() {
806 let w = PanelWrap::build_ansi(Arc::from(RED_THEN_PLAIN), 4, WrapMode::Wrap);
808 assert_eq!(w.total_rows(), 3);
809 let rows = w.visible_window(0, 3);
810 assert_eq!(row_text(&rows[0]), "red ");
811 assert_eq!(rows[0].spans[0].content.as_ref(), "red");
813 assert_eq!(rows[0].spans[0].style.fg, Some(Color::Red));
814 }
815
816 #[test]
817 fn clip_mode_keeps_colour_on_the_single_clipped_row() {
818 let w = PanelWrap::build_ansi(Arc::from(RED_THEN_PLAIN), 4, WrapMode::Clip);
819 assert_eq!(w.total_rows(), 1);
820 let rows = w.visible_window(0, 5);
821 assert_eq!(rows.len(), 1);
822 assert_eq!(row_text(&rows[0]), "red ", "clipped to width 4");
823 assert_eq!(rows[0].spans[0].style.fg, Some(Color::Red));
824 }
825
826 #[test]
827 fn ansi_and_plain_switch_forces_a_rebuild() {
828 let raw: Arc<str> = Arc::from(RED_THEN_PLAIN);
829 let mut cache: Option<PanelWrap> = None;
830 PanelWrap::rebuild_if_needed_ansi(&mut cache, &raw, 40, WrapMode::Wrap);
831 assert!(cache.as_ref().unwrap().line_styles.is_some());
832 let ptr = cache.as_ref().unwrap().source.as_ptr();
834 PanelWrap::rebuild_if_needed_ansi(&mut cache, &raw, 40, WrapMode::Wrap);
835 assert_eq!(cache.as_ref().unwrap().source.as_ptr(), ptr);
836 PanelWrap::rebuild_if_needed_with(&mut cache, &raw, 40, WrapMode::Wrap);
838 assert!(cache.as_ref().unwrap().line_styles.is_none());
839 }
840}