Skip to main content

tui_panel_select/
wrapcache.rs

1//! Shared line/wrap-structure cache backing both the Request JSON and
2//! Response panels' rendering *and* their text selection.
3//!
4//! A panel's underlying text (an HTTP response body, a JSON request preview)
5//! is split once into raw (unwrapped) lines and their wrapped-row extents —
6//! not on every redraw — so scrolling/dragging a selection over an
7//! "obscenely large" body costs only what's on screen, never the whole
8//! body (see `rebuild_if_needed`/`visible_window`). The same structure also
9//! converts between *screen* space (a wrapped row/col, valid only for the
10//! current frame's scroll + panel width) and *logical* space (a raw line
11//! index + character offset, stable across resizes/rewraps/rescrolls) —
12//! which is what lets a selection survive a panel resize by staying on the
13//! same characters instead of the same terminal coordinates.
14
15use 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/// How a panel lays out raw lines wider than its inner width.
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
25pub enum WrapMode {
26    /// Break each raw line every `width` columns onto as many rows as needed
27    /// (the default). One raw line may occupy several screen rows.
28    #[default]
29    Wrap,
30    /// Render each raw line on exactly one screen row, clipping anything past
31    /// the panel's right edge — no wrapping and no horizontal scroll. One raw
32    /// line always maps to exactly one row, which is what a panel that
33    /// displays pre-formatted, column-aligned output (e.g. program output
34    /// echoed verbatim) wants.
35    Clip,
36}
37
38/// A position in a panel's logical (unwrapped) text: which raw line
39/// (0-based), and which character offset within it (0-based; may equal the
40/// line's own length to mean "just past its last character"). Deliberately
41/// never a screen/terminal coordinate, so it stays valid across rewraps.
42#[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
54/// Per-line style runs `(char_from, char_to_exclusive, style)`, one inner
55/// `Vec` per raw line, aligned to the plain text's characters. Only populated
56/// for ANSI content (the `ansi` feature); `None` means "render unstyled".
57type LineStyles = Vec<Vec<(usize, usize, Style)>>;
58
59/// Exclusive prefix sum of wrapped-row counts across a panel's raw lines:
60/// `cum[i]` = total wrapped rows in lines `0..i`. `cum.len() == line_count +
61/// 1`; `*cum.last()` is the grand total (0 for no lines at all). Also caches
62/// each line's own character length (`lens`) — computed once here, from the
63/// same pass that already has to walk every line to determine wrapped-row
64/// counts — so `PanelWrap::line_char_len` never has to re-scan a line's
65/// characters itself (an O(1) selection/highlight primitive, even for a
66/// single enormous line).
67struct 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                // Clip mode collapses every raw line onto a single row.
81                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    /// The raw line index and row-offset-within-that-line for absolute
99    /// wrapped row `row`, found by binary search (not a linear scan) so
100    /// locating a scroll position deep into a huge body stays cheap.
101    fn locate(&self, row: u32) -> (usize, u32) {
102        if self.cum.len() <= 1 {
103            return (0, 0);
104        }
105        // First index whose cumulative count exceeds `row`; the line just
106        // before it is the one containing `row`.
107        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
113/// Cached line/wrap structure for one panel's text, rebuilt only when its
114/// content or width actually changes (see [`PanelWrap::rebuild_if_needed`]).
115pub struct PanelWrap {
116    /// The exact text (or, in ANSI mode, the raw text *with* escape
117    /// sequences) this cache was built from — kept for a cheap `Arc::ptr_eq`
118    /// "has the content changed?" check. In plain mode this is the same `Arc`
119    /// as [`source`](Self::source); in ANSI mode it's the un-stripped input.
120    raw: Arc<str>,
121    /// The plain (ANSI-stripped) text that all geometry, selection and copy
122    /// operate on — kept alive so `line_ranges` (byte offsets into it) stay
123    /// valid.
124    source: Arc<str>,
125    /// Byte (start, end) of each raw line within `source` (split on '\n',
126    /// stripping a trailing '\r', matching `str::lines()`).
127    line_ranges: Vec<(usize, usize)>,
128    rows: LineRows,
129    width: usize,
130    mode: WrapMode,
131    /// Per-line style runs `(char_from, char_to_exclusive, style)` for ANSI
132    /// content, aligned to `source`'s characters; `None` for plain text
133    /// (rendered without styling). Only ever populated via the `ansi`
134    /// feature.
135    line_styles: Option<LineStyles>,
136    /// The last `visible_window` result, keyed by the `(scroll, height)` it
137    /// was computed for. Most frames redraw with an unchanged scroll
138    /// position, so this turns those into an O(1) clone of a handful of
139    /// already-wrapped rows instead of re-wrapping anything — no per-frame
140    /// work proportional to content size, no matter how large the body or
141    /// how long an individual line is.
142    last_window: RefCell<Option<(u16, u16, Vec<Line<'static>>)>>,
143}
144
145impl PanelWrap {
146    /// Build fresh from `source` at `width` columns, wrapping long lines
147    /// ([`WrapMode::Wrap`]). O(source length) — call only when content/width
148    /// has actually changed (see `rebuild_if_needed`), never unconditionally
149    /// on every frame.
150    pub fn build(source: Arc<str>, width: usize) -> Self {
151        Self::build_with(source, width, WrapMode::Wrap)
152    }
153
154    /// Build fresh from plain `source` with an explicit [`WrapMode`].
155    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    /// Split `source` into raw-line byte ranges (on '\n', dropping a trailing
177    /// '\r'), matching `str::lines()`.
178    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    /// Build fresh from ANSI-coloured `raw` with an explicit [`WrapMode`]. The
199    /// escape sequences are parsed once into per-line style runs; all
200    /// geometry, selection and copy operate on the plain, stripped text, so
201    /// colour is purely a rendering concern. Requires the `ansi` feature.
202    #[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        // Byte ranges built directly from the plain lines we just produced, so
207        // they stay exactly aligned with `styles` (one entry per line).
208        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; // skip the '\n' the join inserts
215        }
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    /// Rebuild only if `source`'s identity (by pointer — a new response/edit
233    /// always produces a fresh allocation) or `width` differ from what's
234    /// cached; otherwise this is a no-op, keeping repeated frames (drags,
235    /// idle redraws) cheap regardless of how large the content is. Plain
236    /// text, [`WrapMode::Wrap`].
237    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    /// Like [`rebuild_if_needed`](Self::rebuild_if_needed) but for plain text
242    /// with an explicit [`WrapMode`]. Also rebuilds if the mode changed, or if
243    /// the cache currently holds ANSI-styled content.
244    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    /// Like [`rebuild_if_needed_with`](Self::rebuild_if_needed_with) but for
265    /// ANSI-coloured content. Also rebuilds if the mode changed, or if the
266    /// cache currently holds plain content. Requires the `ansi` feature.
267    #[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    /// This panel's line-layout mode.
289    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    /// The exact, unmodified text this cache was built from — every line,
298    /// with its original line endings, not just what's currently scrolled
299    /// into view. Used for "copy the whole panel" (no selection needed).
300    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    /// The exact wrapped rows visible in a `height`-row window starting at
318    /// absolute wrapped-row `scroll` — the only rows actually wrapped, and
319    /// only the portion of each raw line that window actually covers
320    /// (`wrap_line_window`), regardless of the total content size or how
321    /// long any single raw line is. Repeated calls with the same
322    /// `(scroll, height)` (the common case across idle/unchanged frames)
323    /// hit `last_window` and do no wrapping work at all.
324    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    /// [`WrapMode::Wrap`] window: wrap only the rows actually on screen.
343    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    /// [`WrapMode::Clip`] window: one row per raw line, each clipped to
370    /// `width` characters (so a single enormous line still costs only what's
371    /// on screen).
372    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    /// Wrap only a bounded window of a *styled* line: skip `skip_rows` whole
387    /// wrapped rows, then wrap at most `max_rows` more — without materialising
388    /// the rest of the line (the styled counterpart of `wrap_line_window`).
389    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    /// The styled spans for characters `[c0, c1)` of raw line `idx`. Plain
419    /// content yields a single unstyled span; ANSI content splits the slice at
420    /// its style-run boundaries so each run keeps its colour.
421    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    /// Convert a logical [`TextPos`] into its absolute wrapped-row index and
460    /// column-within-that-row — the reverse of [`Self::row_col_to_textpos`],
461    /// used to project a (resize-invariant) selection back onto the current
462    /// frame's screen space for highlighting or scroll-into-view.
463    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        // In clip mode every raw line is exactly one row, so the row is just
471        // the line's cumulative index and the column maps straight through.
472        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    /// Convert an absolute wrapped-row index + column-in-row (screen space)
482    /// into the logical [`TextPos`] it corresponds to — the reverse of
483    /// [`Self::textpos_to_row_col`], used to map a mouse click/drag onto
484    /// real content.
485    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        // `col` may be `usize::MAX` (callers use this to mean "clamp to the
497        // end of the line", e.g. auto-scroll snapping the selection cursor
498        // to a row's last character) — add with saturation so that intent
499        // doesn't overflow before the `.min(len)` clamp gets a chance to
500        // apply.
501        TextPos::new(line, base.saturating_add(col).min(len))
502    }
503}
504
505/// Parse ANSI-coloured `raw` into per-line plain text plus per-line style runs
506/// `(char_from, char_to_exclusive, style)`. The two are produced in one pass so
507/// they stay exactly aligned character-for-character.
508#[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        // A trailing carriage return belongs to the line ending, not the line
533        // (matching `str::lines()`); drop it and clamp the last run.
534        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        // Two lines, each far wider than the width; clip keeps them 1 row each.
573        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        // Row 0 is the (clipped) first 10 chars of the long line; row 1 is the
577        // whole short line.
578        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        // Wrapped row == line index; column maps 1:1 (no wrap offset).
588        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        // A column past the clip width still resolves to the same line.
591        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); // width 4 clips "line N" to "line"
598        let rows = w.visible_window(500, 3);
599        assert_eq!(rows.len(), 3);
600        assert_eq!(row_text(&rows[0]), "line");
601        // Each visible line is clipped to 4 chars but still one row per line.
602        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        // "0123456789ABCDE" (15 chars) at width 10 -> 2 rows; "" -> 1 row.
631        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); // rows 0: "0123456789", row 1: "ABCDE"
638        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        // A position exactly at the line's own length (cursor "past the end").
643        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        // "line 50000" is 10 chars; at width 20 that's 1 row per line, so
651        // wrapped-row 50_000 should land exactly on line 50_000, col 0.
652        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    /// A single raw line with no newlines at all (e.g. a huge base64 blob or
672    /// minified JSON payload) must still produce a correct, small window
673    /// regardless of where the scroll offset falls inside it — and must do
674    /// so without ever wrapping the whole line (this used to cost O(line
675    /// length) per redraw and grind the app to a halt; see also the timing
676    /// regression test below).
677    #[test]
678    fn visible_window_is_correct_for_a_single_enormous_unbroken_line() {
679        let body: String = "abcdefghij".repeat(200_000); // 2,000,000 chars, one line
680        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        // Deep into the line: row 50_000 covers chars [500_000, 500_010).
693        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        // Repeated calls with the same (scroll, height) hit the cache and
699        // must return identical content.
700        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    /// Regression test for the reported "obscenely large response makes the
713    /// whole app grind to a halt" bug: a single multi-megabyte unwrapped
714    /// line used to cost O(line length) on *every single redraw* (both in
715    /// `visible_window`'s per-line `wrap_line` call and in
716    /// `PanelWrap::line_char_len`'s repeated `.chars().count()`), which
717    /// alone took >100ms per frame for a 5MB line. This asserts many
718    /// repeated redraws of such a line stay fast, with a bound generous
719    /// enough not to flake on slow CI hardware while still catching an
720    /// accidental return to O(line length)-per-frame behaviour.
721    #[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        // Same Arc, same width -> must not rebuild (same backing pointer).
750        PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
751        assert_eq!(cache.as_ref().unwrap().source.as_ptr(), first_ptr);
752        // Width changed -> must rebuild.
753        PanelWrap::rebuild_if_needed(&mut cache, &source, 20);
754        assert_eq!(cache.as_ref().unwrap().width, 20);
755        // A genuinely new Arc (even with equal content) -> must rebuild too,
756        // since a new response/edit always allocates fresh.
757        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        // Selection/geometry see the plain text, not the escape sequences.
778        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        // First span is the red "red"; the rest is unstyled " plain".
790        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        // "red" (3) + " plain" (6) = 9 chars; width 4 wraps to 3 rows.
807        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        // The 'd' at the wrap boundary keeps the red colour.
812        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        // Same Arc + width + mode -> no rebuild.
833        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        // Switching to the plain builder must rebuild (styled -> unstyled).
837        PanelWrap::rebuild_if_needed_with(&mut cache, &raw, 40, WrapMode::Wrap);
838        assert!(cache.as_ref().unwrap().line_styles.is_none());
839    }
840}