1use std::cell::RefCell;
16use std::sync::Arc;
17
18use ratatui::style::{Color, Modifier, Style};
19use ratatui::text::{Line, Span};
20
21use crate::wrap::{wrap_line, wrap_line_window, wrapped_row_count};
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub struct WrapMarker {
36 pub glyph: char,
39 pub style: Style,
42}
43
44impl Default for WrapMarker {
45 fn default() -> Self {
49 Self::builder().build()
50 }
51}
52
53impl WrapMarker {
54 pub fn builder() -> WraperMarkerBuilder {
55 WraperMarkerBuilder {
56 glyph: '↵',
57 style: Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM),
58 }
59 }
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub struct WraperMarkerBuilder {
65 pub glyph: char,
68 pub style: Style,
71}
72
73impl WraperMarkerBuilder {
74 pub fn build(self) -> WrapMarker {
75 WrapMarker {
76 glyph: self.glyph,
77 style: self.style,
78 }
79 }
80
81 pub fn style(mut self, style: Style) -> WraperMarkerBuilder {
83 self.style = style;
84 self
85 }
86
87 pub fn glyph(mut self, glyph: char) -> WraperMarkerBuilder {
89 self.glyph = glyph;
90 self
91 }
92}
93
94fn effective_wrap_width(width: usize, mode: WrapMode, has_marker: bool) -> usize {
100 if has_marker && mode == WrapMode::Wrap && width >= 2 {
101 width - 1
102 } else {
103 width
104 }
105}
106
107#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
109pub enum WrapMode {
110 #[default]
113 Wrap,
114 Clip,
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
127pub struct TextPos {
128 pub line: usize,
129 pub col: usize,
130}
131
132impl TextPos {
133 pub fn new(line: usize, col: usize) -> Self {
134 Self { line, col }
135 }
136}
137
138type LineStyles = Vec<Vec<(usize, usize, Style)>>;
142
143struct LineRows {
152 cum: Vec<u32>,
153 lens: Vec<usize>,
154}
155
156impl LineRows {
157 fn build(char_lens: impl Iterator<Item = usize>, width: usize, mode: WrapMode) -> Self {
158 let mut cum = vec![0u32];
159 let mut lens = Vec::new();
160 let mut total = 0u32;
161 for len in char_lens {
162 let rows = match mode {
163 WrapMode::Wrap => wrapped_row_count(len, width) as u32,
164 WrapMode::Clip => 1,
166 };
167 total += rows;
168 cum.push(total);
169 lens.push(len);
170 }
171 Self { cum, lens }
172 }
173
174 fn total_rows(&self) -> u32 {
175 (*self.cum.last().unwrap_or(&0)).max(1)
176 }
177
178 fn line_count(&self) -> usize {
179 self.cum.len().saturating_sub(1)
180 }
181
182 fn locate(&self, row: u32) -> (usize, u32) {
186 if self.cum.len() <= 1 {
187 return (0, 0);
188 }
189 let idx = self.cum.partition_point(|&c| c <= row);
192 let line = idx.saturating_sub(1).min(self.cum.len() - 2);
193 (line, row - self.cum[line])
194 }
195}
196
197pub struct PanelWrap {
200 raw: Arc<str>,
205 source: Arc<str>,
209 line_ranges: Vec<(usize, usize)>,
212 rows: LineRows,
213 width: usize,
214 wrap_width: usize,
220 mode: WrapMode,
221 marker: Option<WrapMarker>,
224 line_styles: Option<LineStyles>,
229 last_window: RefCell<Option<(u16, u16, Vec<Line<'static>>)>>,
236}
237
238impl PanelWrap {
239 pub fn build(source: Arc<str>, width: usize) -> Self {
244 Self::build_with(source, width, WrapMode::Wrap)
245 }
246
247 pub fn build_with(source: Arc<str>, width: usize, mode: WrapMode) -> Self {
249 Self::build_with_marker(source, width, mode, None)
250 }
251
252 pub fn build_with_marker(
256 source: Arc<str>,
257 width: usize,
258 mode: WrapMode,
259 marker: Option<WrapMarker>,
260 ) -> Self {
261 let wrap_width = effective_wrap_width(width, mode, marker.is_some());
262 let line_ranges = Self::split_line_ranges(&source);
263 let rows = LineRows::build(
264 line_ranges
265 .iter()
266 .map(|&(s, e)| source[s..e].chars().count()),
267 wrap_width,
268 mode,
269 );
270 Self {
271 raw: Arc::clone(&source),
272 source,
273 line_ranges,
274 rows,
275 width,
276 wrap_width,
277 mode,
278 marker,
279 line_styles: None,
280 last_window: RefCell::new(None),
281 }
282 }
283
284 fn split_line_ranges(source: &str) -> Vec<(usize, usize)> {
287 let mut line_ranges = Vec::new();
288 let bytes = source.as_bytes();
289 let mut start = 0usize;
290 for (i, &b) in bytes.iter().enumerate() {
291 if b == b'\n' {
292 let mut end = i;
293 if end > start && bytes[end - 1] == b'\r' {
294 end -= 1;
295 }
296 line_ranges.push((start, end));
297 start = i + 1;
298 }
299 }
300 if start < bytes.len() || line_ranges.is_empty() {
301 line_ranges.push((start, bytes.len()));
302 }
303 line_ranges
304 }
305
306 #[cfg(feature = "ansi")]
311 pub fn build_ansi(raw: Arc<str>, width: usize, mode: WrapMode) -> Self {
312 Self::build_ansi_with_marker(raw, width, mode, None)
313 }
314
315 #[cfg(feature = "ansi")]
318 pub fn build_ansi_with_marker(
319 raw: Arc<str>,
320 width: usize,
321 mode: WrapMode,
322 marker: Option<WrapMarker>,
323 ) -> Self {
324 let wrap_width = effective_wrap_width(width, mode, marker.is_some());
325 let (plain_lines, styles) = parse_ansi(&raw);
326 let source: Arc<str> = Arc::from(plain_lines.join("\n"));
327 let mut line_ranges = Vec::with_capacity(plain_lines.len().max(1));
330 let mut pos = 0usize;
331 for line in &plain_lines {
332 let start = pos;
333 let end = start + line.len();
334 line_ranges.push((start, end));
335 pos = end + 1; }
337 if line_ranges.is_empty() {
338 line_ranges.push((0, 0));
339 }
340 let rows = LineRows::build(
341 plain_lines.iter().map(|l| l.chars().count()),
342 wrap_width,
343 mode,
344 );
345 Self {
346 raw,
347 source,
348 line_ranges,
349 rows,
350 width,
351 wrap_width,
352 mode,
353 marker,
354 line_styles: Some(styles),
355 last_window: RefCell::new(None),
356 }
357 }
358
359 pub fn build_styled(lines: &[Line<'_>], width: usize) -> Self {
372 Self::build_styled_with_marker(lines, width, WrapMode::Wrap, None)
373 }
374
375 pub fn build_styled_with_marker(
378 lines: &[Line<'_>],
379 width: usize,
380 mode: WrapMode,
381 marker: Option<WrapMarker>,
382 ) -> Self {
383 let wrap_width = effective_wrap_width(width, mode, marker.is_some());
384 let mut plain_lines: Vec<String> = Vec::with_capacity(lines.len().max(1));
385 let mut styles: LineStyles = Vec::with_capacity(lines.len().max(1));
386 for line in lines {
387 let mut text = String::new();
388 let mut runs: Vec<(usize, usize, Style)> = Vec::new();
389 let mut char_pos = 0usize;
390 for span in &line.spans {
391 let n = span.content.chars().count();
392 if n == 0 {
393 continue;
394 }
395 text.push_str(&span.content);
396 runs.push((char_pos, char_pos + n, span.style));
397 char_pos += n;
398 }
399 plain_lines.push(text);
400 styles.push(runs);
401 }
402 if plain_lines.is_empty() {
405 plain_lines.push(String::new());
406 styles.push(Vec::new());
407 }
408 let source: Arc<str> = Arc::from(plain_lines.join("\n"));
409 let mut line_ranges = Vec::with_capacity(plain_lines.len());
412 let mut pos = 0usize;
413 for line in &plain_lines {
414 let start = pos;
415 let end = start + line.len();
416 line_ranges.push((start, end));
417 pos = end + 1; }
419 let rows = LineRows::build(
420 plain_lines.iter().map(|l| l.chars().count()),
421 wrap_width,
422 mode,
423 );
424 Self {
425 raw: Arc::clone(&source),
426 source,
427 line_ranges,
428 rows,
429 width,
430 wrap_width,
431 mode,
432 marker,
433 line_styles: Some(styles),
434 last_window: RefCell::new(None),
435 }
436 }
437
438 pub fn rebuild_if_needed(cache: &mut Option<PanelWrap>, source: &Arc<str>, width: usize) {
444 Self::rebuild_if_needed_with(cache, source, width, WrapMode::Wrap);
445 }
446
447 pub fn rebuild_if_needed_with(
451 cache: &mut Option<PanelWrap>,
452 source: &Arc<str>,
453 width: usize,
454 mode: WrapMode,
455 ) {
456 Self::rebuild_if_needed_marker(cache, source, width, mode, None);
457 }
458
459 pub fn rebuild_if_needed_marker(
463 cache: &mut Option<PanelWrap>,
464 source: &Arc<str>,
465 width: usize,
466 mode: WrapMode,
467 marker: Option<WrapMarker>,
468 ) {
469 let stale = match cache {
470 Some(c) => {
471 !Arc::ptr_eq(&c.raw, source)
472 || c.width != width
473 || c.mode != mode
474 || c.marker != marker
475 || c.line_styles.is_some()
476 }
477 None => true,
478 };
479 if stale {
480 *cache = Some(PanelWrap::build_with_marker(
481 Arc::clone(source),
482 width,
483 mode,
484 marker,
485 ));
486 }
487 }
488
489 #[cfg(feature = "ansi")]
493 pub fn rebuild_if_needed_ansi(
494 cache: &mut Option<PanelWrap>,
495 raw: &Arc<str>,
496 width: usize,
497 mode: WrapMode,
498 ) {
499 Self::rebuild_if_needed_ansi_marker(cache, raw, width, mode, None);
500 }
501
502 #[cfg(feature = "ansi")]
506 pub fn rebuild_if_needed_ansi_marker(
507 cache: &mut Option<PanelWrap>,
508 raw: &Arc<str>,
509 width: usize,
510 mode: WrapMode,
511 marker: Option<WrapMarker>,
512 ) {
513 let stale = match cache {
514 Some(c) => {
515 !Arc::ptr_eq(&c.raw, raw)
516 || c.width != width
517 || c.mode != mode
518 || c.marker != marker
519 || c.line_styles.is_none()
520 }
521 None => true,
522 };
523 if stale {
524 *cache = Some(PanelWrap::build_ansi_with_marker(
525 Arc::clone(raw),
526 width,
527 mode,
528 marker,
529 ));
530 }
531 }
532
533 pub fn mode(&self) -> WrapMode {
535 self.mode
536 }
537
538 pub fn wrap_width(&self) -> usize {
543 self.wrap_width
544 }
545
546 pub fn marker(&self) -> Option<WrapMarker> {
548 self.marker
549 }
550
551 pub fn line_count(&self) -> usize {
552 self.rows.line_count()
553 }
554
555 pub fn source(&self) -> &str {
559 &self.source
560 }
561
562 pub fn line_text(&self, idx: usize) -> &str {
563 let (s, e) = self.line_ranges[idx];
564 &self.source[s..e]
565 }
566
567 pub fn line_char_len(&self, idx: usize) -> usize {
568 self.rows.lens.get(idx).copied().unwrap_or(0)
569 }
570
571 pub fn total_rows(&self) -> u32 {
572 self.rows.total_rows()
573 }
574
575 pub fn visible_window(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
583 if height == 0 || self.line_count() == 0 {
584 return Vec::new();
585 }
586 if let Some((cached_scroll, cached_height, cached)) = self.last_window.borrow().as_ref()
587 && *cached_scroll == scroll
588 && *cached_height == height
589 {
590 return cached.clone();
591 }
592 let out = match self.mode {
593 WrapMode::Clip => self.visible_window_clip(scroll, height),
594 WrapMode::Wrap => self.visible_window_wrap(scroll, height),
595 };
596 *self.last_window.borrow_mut() = Some((scroll, height, out.clone()));
597 out
598 }
599
600 fn visible_window_wrap(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
602 let (start_line, row_in_line) = self.rows.locate(scroll as u32);
603 let height_usize = height as usize;
604 let mut out: Vec<Line<'static>> = Vec::with_capacity(height_usize);
605 let mut skip = row_in_line as usize;
606 for idx in start_line..self.line_count() {
607 if out.len() >= height_usize {
608 break;
609 }
610 let budget = height_usize - out.len();
611 let mut rows = if self.line_styles.is_none() {
612 wrap_line_window(self.line_text(idx), self.wrap_width, skip, budget)
613 } else {
614 self.wrap_line_window_styled(idx, skip, budget)
615 };
616 self.mark_continued_rows(idx, skip, &mut rows);
622 out.extend(rows);
623 skip = 0;
624 }
625 out.truncate(height_usize);
626 out
627 }
628
629 fn mark_continued_rows(&self, idx: usize, first_row: usize, rows: &mut [Line<'static>]) {
635 let Some(marker) = self.marker else {
636 return;
637 };
638 if self.wrap_width >= self.width {
642 return;
643 }
644 let total_in_line = wrapped_row_count(self.line_char_len(idx), self.wrap_width);
645 for (k, line) in rows.iter_mut().enumerate() {
646 let row_in_line = first_row + k;
647 if row_in_line + 1 < total_in_line {
648 line.spans
649 .push(Span::styled(marker.glyph.to_string(), marker.style));
650 }
651 }
652 }
653
654 fn visible_window_clip(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
658 let start = scroll as usize;
659 let height_usize = height as usize;
660 let mut out: Vec<Line<'static>> = Vec::with_capacity(height_usize);
661 for idx in start..self.line_count() {
662 if out.len() >= height_usize {
663 break;
664 }
665 let end = self.line_char_len(idx).min(self.width);
666 out.push(Line::from(self.styled_spans(idx, 0, end)));
667 }
668 out
669 }
670
671 fn wrap_line_window_styled(
675 &self,
676 idx: usize,
677 skip_rows: usize,
678 max_rows: usize,
679 ) -> Vec<Line<'static>> {
680 if max_rows == 0 {
681 return Vec::new();
682 }
683 if self.wrap_width == 0 {
684 return if skip_rows == 0 {
685 vec![Line::from(self.styled_spans(
686 idx,
687 0,
688 self.line_char_len(idx),
689 ))]
690 } else {
691 Vec::new()
692 };
693 }
694 let c0 = skip_rows.saturating_mul(self.wrap_width);
695 let c1 = c0.saturating_add(max_rows.saturating_mul(self.wrap_width));
696 let spans = self.styled_spans(idx, c0, c1);
697 if spans.is_empty() {
698 return Vec::new();
699 }
700 wrap_line(Line::from(spans), self.wrap_width)
701 }
702
703 fn styled_spans(&self, idx: usize, c0: usize, c1: usize) -> Vec<Span<'static>> {
707 if c1 <= c0 {
708 return Vec::new();
709 }
710 let text = self.line_text(idx);
711 let slice: String = text.chars().skip(c0).take(c1 - c0).collect();
712 if slice.is_empty() {
713 return Vec::new();
714 }
715 let runs = match &self.line_styles {
716 None => return vec![Span::raw(slice)],
717 Some(all) => all.get(idx).map(|v| v.as_slice()).unwrap_or(&[]),
718 };
719 if runs.is_empty() {
720 return vec![Span::raw(slice)];
721 }
722 let style_at = |abs: usize| {
723 runs.iter()
724 .find(|&&(s, e, _)| abs >= s && abs < e)
725 .map(|&(_, _, st)| st)
726 .unwrap_or_default()
727 };
728 let chars: Vec<char> = slice.chars().collect();
729 let mut spans = Vec::new();
730 let mut i = 0usize;
731 while i < chars.len() {
732 let style = style_at(c0 + i);
733 let mut j = i + 1;
734 while j < chars.len() && style_at(c0 + j) == style {
735 j += 1;
736 }
737 let seg: String = chars[i..j].iter().collect();
738 spans.push(Span::styled(seg, style));
739 i = j;
740 }
741 spans
742 }
743
744 pub fn textpos_to_row_col(&self, pos: TextPos) -> (u32, usize) {
749 if self.line_count() == 0 {
750 return (0, 0);
751 }
752 let line = pos.line.min(self.line_count() - 1);
753 let len = self.line_char_len(line);
754 let col = pos.col.min(len);
755 if self.mode == WrapMode::Clip || self.wrap_width == 0 {
758 return (self.rows.cum[line], col);
759 }
760 let rows_in_line = wrapped_row_count(len, self.wrap_width) as u32;
761 let row_in_line = ((col / self.wrap_width) as u32).min(rows_in_line.saturating_sub(1));
762 let col_in_row = col.saturating_sub(row_in_line as usize * self.wrap_width);
763 (self.rows.cum[line] + row_in_line, col_in_row)
764 }
765
766 pub fn row_col_to_textpos(&self, row: u32, col: usize) -> TextPos {
771 if self.line_count() == 0 {
772 return TextPos::new(0, 0);
773 }
774 let (line, row_in_line) = self.rows.locate(row);
775 let len = self.line_char_len(line);
776 let base = if self.wrap_width == 0 {
777 0
778 } else {
779 row_in_line as usize * self.wrap_width
780 };
781 TextPos::new(line, base.saturating_add(col).min(len))
787 }
788}
789
790#[cfg(feature = "ansi")]
794fn parse_ansi(raw: &str) -> (Vec<String>, LineStyles) {
795 use ansi_to_tui::IntoText;
796 use ratatui::text::Text;
797
798 let text = raw
799 .into_text()
800 .unwrap_or_else(|_| Text::raw(raw.to_string()));
801 let mut plain_lines: Vec<String> = Vec::with_capacity(text.lines.len().max(1));
802 let mut styles: LineStyles = Vec::with_capacity(text.lines.len().max(1));
803 for line in &text.lines {
804 let mut plain = String::new();
805 let mut runs: Vec<(usize, usize, Style)> = Vec::new();
806 let mut col = 0usize;
807 for span in &line.spans {
808 let content: &str = span.content.as_ref();
809 let n = content.chars().count();
810 if n == 0 {
811 continue;
812 }
813 runs.push((col, col + n, line.style.patch(span.style)));
814 plain.push_str(content);
815 col += n;
816 }
817 if plain.ends_with('\r') {
820 plain.pop();
821 let new_len = plain.chars().count();
822 if let Some(last) = runs.last_mut() {
823 last.1 = last.1.min(new_len);
824 if last.0 >= last.1 {
825 runs.pop();
826 }
827 }
828 }
829 plain_lines.push(plain);
830 styles.push(runs);
831 }
832 if plain_lines.is_empty() {
833 plain_lines.push(String::new());
834 styles.push(Vec::new());
835 }
836 (plain_lines, styles)
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842
843 fn wrap(text: &str, width: usize) -> PanelWrap {
844 PanelWrap::build(Arc::from(text), width)
845 }
846
847 fn clip(text: &str, width: usize) -> PanelWrap {
848 PanelWrap::build_with(Arc::from(text), width, WrapMode::Clip)
849 }
850
851 fn row_text(line: &Line<'static>) -> String {
852 line.spans.iter().map(|s| s.content.as_ref()).collect()
853 }
854
855 #[test]
856 fn clip_mode_maps_one_row_per_line_regardless_of_length() {
857 let w = clip("0123456789ABCDE\nshort", 10);
859 assert_eq!(w.line_count(), 2);
860 assert_eq!(w.total_rows(), 2, "one row per raw line, no wrapping");
861 let rows = w.visible_window(0, 5);
864 assert_eq!(rows.len(), 2);
865 assert_eq!(row_text(&rows[0]), "0123456789", "clipped to width");
866 assert_eq!(row_text(&rows[1]), "short");
867 }
868
869 #[test]
870 fn clip_mode_row_and_textpos_map_straight_through() {
871 let w = clip("0123456789ABCDE\nsecond", 10);
872 assert_eq!(w.textpos_to_row_col(TextPos::new(1, 3)), (1, 3));
874 assert_eq!(w.row_col_to_textpos(1, 3), TextPos::new(1, 3));
875 assert_eq!(w.row_col_to_textpos(0, 4), TextPos::new(0, 4));
877 }
878
879 #[test]
880 fn clip_mode_scrolls_by_whole_lines() {
881 let body: String = (0..1000).map(|i| format!("line {i}\n")).collect();
882 let w = clip(&body, 4); let rows = w.visible_window(500, 3);
884 assert_eq!(rows.len(), 3);
885 assert_eq!(row_text(&rows[0]), "line");
886 assert_eq!(w.total_rows(), 1000);
888 }
889
890 #[test]
891 fn splits_lines_like_str_lines_including_trailing_newline_and_crlf() {
892 let w = wrap("a\r\nb\nc", 10);
893 assert_eq!(w.line_count(), 3);
894 assert_eq!(w.line_text(0), "a");
895 assert_eq!(w.line_text(1), "b");
896 assert_eq!(w.line_text(2), "c");
897
898 let w2 = wrap("a\nb\n", 10);
899 assert_eq!(
900 w2.line_count(),
901 2,
902 "no trailing empty line after a final \\n, matching str::lines()"
903 );
904 }
905
906 #[test]
907 fn empty_body_has_one_line_and_one_row() {
908 let w = wrap("", 10);
909 assert_eq!(w.line_count(), 1);
910 assert_eq!(w.total_rows(), 1);
911 }
912
913 #[test]
914 fn total_rows_accounts_for_wrapping_long_lines() {
915 let w = wrap("0123456789ABCDE\n", 10);
917 assert_eq!(w.total_rows(), 2);
918 }
919
920 #[test]
921 fn row_col_and_textpos_roundtrip_for_a_wrapped_line() {
922 let w = wrap("0123456789ABCDE", 10); assert_eq!(w.row_col_to_textpos(0, 3), TextPos::new(0, 3));
924 assert_eq!(w.row_col_to_textpos(1, 2), TextPos::new(0, 12));
925 assert_eq!(w.textpos_to_row_col(TextPos::new(0, 3)), (0, 3));
926 assert_eq!(w.textpos_to_row_col(TextPos::new(0, 12)), (1, 2));
927 assert_eq!(w.textpos_to_row_col(TextPos::new(0, 15)), (1, 5));
929 }
930
931 #[test]
932 fn locate_binary_search_finds_the_right_line_for_a_huge_body() {
933 let body: String = (0..100_000).map(|i| format!("line {i}\n")).collect();
934 let w = wrap(&body, 20);
935 assert_eq!(w.row_col_to_textpos(50_000, 0), TextPos::new(50_000, 0));
938 }
939
940 #[test]
941 fn visible_window_only_wraps_the_requested_rows() {
942 let body: String = (0..1000).map(|i| format!("line {i}\n")).collect();
943 let w = wrap(&body, 20);
944 let rows = w.visible_window(500, 5);
945 assert_eq!(rows.len(), 5);
946 let text: Vec<String> = rows
947 .iter()
948 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
949 .collect();
950 assert_eq!(
951 text,
952 vec!["line 500", "line 501", "line 502", "line 503", "line 504"]
953 );
954 }
955
956 #[test]
963 fn visible_window_is_correct_for_a_single_enormous_unbroken_line() {
964 let body: String = "abcdefghij".repeat(200_000); let w = wrap(&body, 10);
966
967 let top = w.visible_window(0, 3);
968 assert_eq!(top.len(), 3);
969 let row0: String = top[0].spans.iter().map(|s| s.content.as_ref()).collect();
970 assert_eq!(row0, "abcdefghij", "row 0 is chars [0, 10)");
971 let row2: String = top[2].spans.iter().map(|s| s.content.as_ref()).collect();
972 assert_eq!(
973 row2, "abcdefghij",
974 "row 2 (chars [20, 30)) lands mid-repeat but still aligned"
975 );
976
977 let mid = w.visible_window(50_000, 2);
979 assert_eq!(mid.len(), 2);
980 let mid_row: String = mid[0].spans.iter().map(|s| s.content.as_ref()).collect();
981 assert_eq!(mid_row, "abcdefghij");
982
983 let again = w.visible_window(50_000, 2);
986 let again_text: Vec<String> = again
987 .iter()
988 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
989 .collect();
990 let mid_text: Vec<String> = mid
991 .iter()
992 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
993 .collect();
994 assert_eq!(again_text, mid_text);
995 }
996
997 #[test]
1007 fn visible_window_stays_fast_across_many_redraws_of_a_single_huge_line() {
1008 use std::time::{Duration, Instant};
1009 let body: String = "x".repeat(5_000_000);
1010 let w = wrap(&body, 78);
1011
1012 let start = Instant::now();
1013 for _ in 0..200 {
1014 let rows = w.visible_window(0, 30);
1015 assert_eq!(
1016 rows.len(),
1017 30,
1018 "the first 30 wrapped rows of a 5,000,000-char line at width 78"
1019 );
1020 }
1021 let elapsed = start.elapsed();
1022 assert!(
1023 elapsed < Duration::from_secs(2),
1024 "200 redraws of a single 5MB line took {elapsed:?} — expected a small fraction of a second"
1025 );
1026 }
1027
1028 #[test]
1029 fn rebuild_if_needed_skips_rebuilding_on_an_unchanged_pointer_and_width() {
1030 let source: Arc<str> = Arc::from("hello\nworld");
1031 let mut cache: Option<PanelWrap> = None;
1032 PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
1033 let first_ptr = cache.as_ref().unwrap().source.as_ptr();
1034 PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
1036 assert_eq!(cache.as_ref().unwrap().source.as_ptr(), first_ptr);
1037 PanelWrap::rebuild_if_needed(&mut cache, &source, 20);
1039 assert_eq!(cache.as_ref().unwrap().width, 20);
1040 let source2: Arc<str> = Arc::from("hello\nworld");
1043 PanelWrap::rebuild_if_needed(&mut cache, &source2, 20);
1044 assert!(Arc::ptr_eq(&cache.as_ref().unwrap().source, &source2));
1045 }
1046
1047 #[test]
1048 fn build_styled_records_plain_geometry_and_keeps_span_colours() {
1049 use ratatui::style::{Color, Style};
1050 let lines = vec![
1052 Line::from(vec![
1053 Span::styled("key", Style::default().fg(Color::Green)),
1054 Span::raw(": value"),
1055 ]),
1056 Line::from(vec![Span::styled(
1057 "second",
1058 Style::default().fg(Color::Blue),
1059 )]),
1060 ];
1061 let w = PanelWrap::build_styled(&lines, 40);
1062 assert_eq!(w.line_count(), 2);
1064 assert_eq!(w.line_text(0), "key: value");
1065 assert_eq!(w.line_char_len(0), 10);
1066 assert_eq!(w.source(), "key: value\nsecond");
1067 let rows = w.visible_window(0, 2);
1069 assert_eq!(row_text(&rows[0]), "key: value");
1070 assert_eq!(rows[0].spans[0].content.as_ref(), "key");
1071 assert_eq!(rows[0].spans[0].style.fg, Some(Color::Green));
1072 assert_ne!(rows[0].spans[1].style.fg, Some(Color::Green));
1073 assert_eq!(rows[1].spans[0].style.fg, Some(Color::Blue));
1074 }
1075
1076 #[test]
1077 fn build_styled_colour_survives_wrapping() {
1078 use ratatui::style::{Color, Style};
1079 let lines = vec![Line::from(vec![Span::styled(
1081 "greenlong",
1082 Style::default().fg(Color::Green),
1083 )])];
1084 let w = PanelWrap::build_styled(&lines, 4);
1085 assert_eq!(w.total_rows(), 3);
1086 let rows = w.visible_window(0, 3);
1087 assert_eq!(row_text(&rows[0]), "gree");
1088 assert_eq!(rows[0].spans[0].style.fg, Some(Color::Green));
1089 assert_eq!(row_text(&rows[2]), "g");
1090 assert_eq!(rows[2].spans[0].style.fg, Some(Color::Green));
1091 }
1092}
1093
1094#[cfg(all(test, feature = "ansi"))]
1095mod ansi_tests {
1096 use super::*;
1097 use ratatui::style::Color;
1098
1099 fn row_text(line: &Line<'static>) -> String {
1100 line.spans.iter().map(|s| s.content.as_ref()).collect()
1101 }
1102
1103 const RED_THEN_PLAIN: &str = "\x1b[31mred\x1b[0m plain";
1104
1105 #[test]
1106 fn geometry_and_copy_use_the_stripped_text() {
1107 let w = PanelWrap::build_ansi(Arc::from(RED_THEN_PLAIN), 40, WrapMode::Wrap);
1108 assert_eq!(w.line_count(), 1);
1110 assert_eq!(w.line_text(0), "red plain");
1111 assert_eq!(w.line_char_len(0), 9);
1112 }
1113
1114 #[test]
1115 fn rendered_rows_keep_their_colour() {
1116 let w = PanelWrap::build_ansi(Arc::from(RED_THEN_PLAIN), 40, WrapMode::Wrap);
1117 let rows = w.visible_window(0, 1);
1118 assert_eq!(rows.len(), 1);
1119 assert_eq!(row_text(&rows[0]), "red plain");
1120 assert_eq!(rows[0].spans[0].content.as_ref(), "red");
1122 assert_eq!(rows[0].spans[0].style.fg, Some(Color::Red));
1123 let plain: String = rows[0].spans[1..]
1124 .iter()
1125 .map(|s| s.content.as_ref())
1126 .collect();
1127 assert_eq!(plain, " plain");
1128 assert_ne!(
1129 rows[0].spans[1].style.fg,
1130 Some(Color::Red),
1131 "the reset run is not red"
1132 );
1133 }
1134
1135 #[test]
1136 fn colour_survives_wrapping_across_a_row_boundary() {
1137 let w = PanelWrap::build_ansi(Arc::from(RED_THEN_PLAIN), 4, WrapMode::Wrap);
1139 assert_eq!(w.total_rows(), 3);
1140 let rows = w.visible_window(0, 3);
1141 assert_eq!(row_text(&rows[0]), "red ");
1142 assert_eq!(rows[0].spans[0].content.as_ref(), "red");
1144 assert_eq!(rows[0].spans[0].style.fg, Some(Color::Red));
1145 }
1146
1147 #[test]
1148 fn clip_mode_keeps_colour_on_the_single_clipped_row() {
1149 let w = PanelWrap::build_ansi(Arc::from(RED_THEN_PLAIN), 4, WrapMode::Clip);
1150 assert_eq!(w.total_rows(), 1);
1151 let rows = w.visible_window(0, 5);
1152 assert_eq!(rows.len(), 1);
1153 assert_eq!(row_text(&rows[0]), "red ", "clipped to width 4");
1154 assert_eq!(rows[0].spans[0].style.fg, Some(Color::Red));
1155 }
1156
1157 #[test]
1158 fn ansi_and_plain_switch_forces_a_rebuild() {
1159 let raw: Arc<str> = Arc::from(RED_THEN_PLAIN);
1160 let mut cache: Option<PanelWrap> = None;
1161 PanelWrap::rebuild_if_needed_ansi(&mut cache, &raw, 40, WrapMode::Wrap);
1162 assert!(cache.as_ref().unwrap().line_styles.is_some());
1163 let ptr = cache.as_ref().unwrap().source.as_ptr();
1165 PanelWrap::rebuild_if_needed_ansi(&mut cache, &raw, 40, WrapMode::Wrap);
1166 assert_eq!(cache.as_ref().unwrap().source.as_ptr(), ptr);
1167 PanelWrap::rebuild_if_needed_with(&mut cache, &raw, 40, WrapMode::Wrap);
1169 assert!(cache.as_ref().unwrap().line_styles.is_none());
1170 }
1171}