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::text::Line;
19
20use crate::wrap::{wrap_line_window, wrapped_row_count};
21
22/// A position in a panel's logical (unwrapped) text: which raw line
23/// (0-based), and which character offset within it (0-based; may equal the
24/// line's own length to mean "just past its last character"). Deliberately
25/// never a screen/terminal coordinate, so it stays valid across rewraps.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
27pub struct TextPos {
28    pub line: usize,
29    pub col: usize,
30}
31
32impl TextPos {
33    pub fn new(line: usize, col: usize) -> Self {
34        Self { line, col }
35    }
36}
37
38/// Exclusive prefix sum of wrapped-row counts across a panel's raw lines:
39/// `cum[i]` = total wrapped rows in lines `0..i`. `cum.len() == line_count +
40/// 1`; `*cum.last()` is the grand total (0 for no lines at all). Also caches
41/// each line's own character length (`lens`) — computed once here, from the
42/// same pass that already has to walk every line to determine wrapped-row
43/// counts — so `PanelWrap::line_char_len` never has to re-scan a line's
44/// characters itself (an O(1) selection/highlight primitive, even for a
45/// single enormous line).
46struct LineRows {
47    cum: Vec<u32>,
48    lens: Vec<usize>,
49}
50
51impl LineRows {
52    fn build(char_lens: impl Iterator<Item = usize>, width: usize) -> Self {
53        let mut cum = vec![0u32];
54        let mut lens = Vec::new();
55        let mut total = 0u32;
56        for len in char_lens {
57            total += wrapped_row_count(len, width) as u32;
58            cum.push(total);
59            lens.push(len);
60        }
61        Self { cum, lens }
62    }
63
64    fn total_rows(&self) -> u32 {
65        (*self.cum.last().unwrap_or(&0)).max(1)
66    }
67
68    fn line_count(&self) -> usize {
69        self.cum.len().saturating_sub(1)
70    }
71
72    /// The raw line index and row-offset-within-that-line for absolute
73    /// wrapped row `row`, found by binary search (not a linear scan) so
74    /// locating a scroll position deep into a huge body stays cheap.
75    fn locate(&self, row: u32) -> (usize, u32) {
76        if self.cum.len() <= 1 {
77            return (0, 0);
78        }
79        // First index whose cumulative count exceeds `row`; the line just
80        // before it is the one containing `row`.
81        let idx = self.cum.partition_point(|&c| c <= row);
82        let line = idx.saturating_sub(1).min(self.cum.len() - 2);
83        (line, row - self.cum[line])
84    }
85}
86
87/// Cached line/wrap structure for one panel's text, rebuilt only when its
88/// content or width actually changes (see [`PanelWrap::rebuild_if_needed`]).
89pub struct PanelWrap {
90    /// The exact text this cache was built from — kept alive so
91    /// `line_ranges` (byte offsets into it) stay valid, and so a cheap
92    /// `Arc::ptr_eq` can detect "the content hasn't changed" without ever
93    /// comparing bytes.
94    source: Arc<str>,
95    /// Byte (start, end) of each raw line within `source` (split on '\n',
96    /// stripping a trailing '\r', matching `str::lines()`).
97    line_ranges: Vec<(usize, usize)>,
98    rows: LineRows,
99    width: usize,
100    /// The last `visible_window` result, keyed by the `(scroll, height)` it
101    /// was computed for. Most frames redraw with an unchanged scroll
102    /// position, so this turns those into an O(1) clone of a handful of
103    /// already-wrapped rows instead of re-wrapping anything — no per-frame
104    /// work proportional to content size, no matter how large the body or
105    /// how long an individual line is.
106    last_window: RefCell<Option<(u16, u16, Vec<Line<'static>>)>>,
107}
108
109impl PanelWrap {
110    /// Build fresh from `source` at `width` columns. O(source length) —
111    /// call only when content/width has actually changed (see
112    /// `rebuild_if_needed`), never unconditionally on every frame.
113    pub fn build(source: Arc<str>, width: usize) -> Self {
114        let mut line_ranges = Vec::new();
115        let bytes = source.as_bytes();
116        let mut start = 0usize;
117        for (i, &b) in bytes.iter().enumerate() {
118            if b == b'\n' {
119                let mut end = i;
120                if end > start && bytes[end - 1] == b'\r' {
121                    end -= 1;
122                }
123                line_ranges.push((start, end));
124                start = i + 1;
125            }
126        }
127        if start < bytes.len() || line_ranges.is_empty() {
128            line_ranges.push((start, bytes.len()));
129        }
130        let rows = LineRows::build(
131            line_ranges
132                .iter()
133                .map(|&(s, e)| source[s..e].chars().count()),
134            width,
135        );
136        Self {
137            source,
138            line_ranges,
139            rows,
140            width,
141            last_window: RefCell::new(None),
142        }
143    }
144
145    /// Rebuild only if `source`'s identity (by pointer — a new response/edit
146    /// always produces a fresh allocation) or `width` differ from what's
147    /// cached; otherwise this is a no-op, keeping repeated frames (drags,
148    /// idle redraws) cheap regardless of how large the content is.
149    pub fn rebuild_if_needed(cache: &mut Option<PanelWrap>, source: &Arc<str>, width: usize) {
150        let stale = match cache {
151            Some(c) => !Arc::ptr_eq(&c.source, source) || c.width != width,
152            None => true,
153        };
154        if stale {
155            *cache = Some(PanelWrap::build(Arc::clone(source), width));
156        }
157    }
158
159    pub fn line_count(&self) -> usize {
160        self.rows.line_count()
161    }
162
163    /// The exact, unmodified text this cache was built from — every line,
164    /// with its original line endings, not just what's currently scrolled
165    /// into view. Used for "copy the whole panel" (no selection needed).
166    pub fn source(&self) -> &str {
167        &self.source
168    }
169
170    pub fn line_text(&self, idx: usize) -> &str {
171        let (s, e) = self.line_ranges[idx];
172        &self.source[s..e]
173    }
174
175    pub fn line_char_len(&self, idx: usize) -> usize {
176        self.rows.lens.get(idx).copied().unwrap_or(0)
177    }
178
179    pub fn total_rows(&self) -> u32 {
180        self.rows.total_rows()
181    }
182
183    /// The exact wrapped rows visible in a `height`-row window starting at
184    /// absolute wrapped-row `scroll` — the only rows actually wrapped, and
185    /// only the portion of each raw line that window actually covers
186    /// (`wrap_line_window`), regardless of the total content size or how
187    /// long any single raw line is. Repeated calls with the same
188    /// `(scroll, height)` (the common case across idle/unchanged frames)
189    /// hit `last_window` and do no wrapping work at all.
190    pub fn visible_window(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
191        if height == 0 || self.line_count() == 0 {
192            return Vec::new();
193        }
194        if let Some((cached_scroll, cached_height, cached)) = self.last_window.borrow().as_ref()
195            && *cached_scroll == scroll
196            && *cached_height == height
197        {
198            return cached.clone();
199        }
200        let (start_line, row_in_line) = self.rows.locate(scroll as u32);
201        let height_usize = height as usize;
202        let mut out: Vec<Line<'static>> = Vec::with_capacity(height_usize);
203        let mut skip = row_in_line as usize;
204        for idx in start_line..self.line_count() {
205            if out.len() >= height_usize {
206                break;
207            }
208            let budget = height_usize - out.len();
209            out.extend(wrap_line_window(
210                self.line_text(idx),
211                self.width,
212                skip,
213                budget,
214            ));
215            skip = 0;
216        }
217        out.truncate(height_usize);
218        *self.last_window.borrow_mut() = Some((scroll, height, out.clone()));
219        out
220    }
221
222    /// Convert a logical [`TextPos`] into its absolute wrapped-row index and
223    /// column-within-that-row — the reverse of [`Self::row_col_to_textpos`],
224    /// used to project a (resize-invariant) selection back onto the current
225    /// frame's screen space for highlighting or scroll-into-view.
226    pub fn textpos_to_row_col(&self, pos: TextPos) -> (u32, usize) {
227        if self.line_count() == 0 {
228            return (0, 0);
229        }
230        let line = pos.line.min(self.line_count() - 1);
231        let len = self.line_char_len(line);
232        let col = pos.col.min(len);
233        if self.width == 0 {
234            return (self.rows.cum[line], col);
235        }
236        let rows_in_line = wrapped_row_count(len, self.width) as u32;
237        let row_in_line = ((col / self.width) as u32).min(rows_in_line.saturating_sub(1));
238        let col_in_row = col.saturating_sub(row_in_line as usize * self.width);
239        (self.rows.cum[line] + row_in_line, col_in_row)
240    }
241
242    /// Convert an absolute wrapped-row index + column-in-row (screen space)
243    /// into the logical [`TextPos`] it corresponds to — the reverse of
244    /// [`Self::textpos_to_row_col`], used to map a mouse click/drag onto
245    /// real content.
246    pub fn row_col_to_textpos(&self, row: u32, col: usize) -> TextPos {
247        if self.line_count() == 0 {
248            return TextPos::new(0, 0);
249        }
250        let (line, row_in_line) = self.rows.locate(row);
251        let len = self.line_char_len(line);
252        let base = if self.width == 0 {
253            0
254        } else {
255            row_in_line as usize * self.width
256        };
257        // `col` may be `usize::MAX` (callers use this to mean "clamp to the
258        // end of the line", e.g. auto-scroll snapping the selection cursor
259        // to a row's last character) — add with saturation so that intent
260        // doesn't overflow before the `.min(len)` clamp gets a chance to
261        // apply.
262        TextPos::new(line, base.saturating_add(col).min(len))
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn wrap(text: &str, width: usize) -> PanelWrap {
271        PanelWrap::build(Arc::from(text), width)
272    }
273
274    #[test]
275    fn splits_lines_like_str_lines_including_trailing_newline_and_crlf() {
276        let w = wrap("a\r\nb\nc", 10);
277        assert_eq!(w.line_count(), 3);
278        assert_eq!(w.line_text(0), "a");
279        assert_eq!(w.line_text(1), "b");
280        assert_eq!(w.line_text(2), "c");
281
282        let w2 = wrap("a\nb\n", 10);
283        assert_eq!(
284            w2.line_count(),
285            2,
286            "no trailing empty line after a final \\n, matching str::lines()"
287        );
288    }
289
290    #[test]
291    fn empty_body_has_one_line_and_one_row() {
292        let w = wrap("", 10);
293        assert_eq!(w.line_count(), 1);
294        assert_eq!(w.total_rows(), 1);
295    }
296
297    #[test]
298    fn total_rows_accounts_for_wrapping_long_lines() {
299        // "0123456789ABCDE" (15 chars) at width 10 -> 2 rows; "" -> 1 row.
300        let w = wrap("0123456789ABCDE\n", 10);
301        assert_eq!(w.total_rows(), 2);
302    }
303
304    #[test]
305    fn row_col_and_textpos_roundtrip_for_a_wrapped_line() {
306        let w = wrap("0123456789ABCDE", 10); // rows 0: "0123456789", row 1: "ABCDE"
307        assert_eq!(w.row_col_to_textpos(0, 3), TextPos::new(0, 3));
308        assert_eq!(w.row_col_to_textpos(1, 2), TextPos::new(0, 12));
309        assert_eq!(w.textpos_to_row_col(TextPos::new(0, 3)), (0, 3));
310        assert_eq!(w.textpos_to_row_col(TextPos::new(0, 12)), (1, 2));
311        // A position exactly at the line's own length (cursor "past the end").
312        assert_eq!(w.textpos_to_row_col(TextPos::new(0, 15)), (1, 5));
313    }
314
315    #[test]
316    fn locate_binary_search_finds_the_right_line_for_a_huge_body() {
317        let body: String = (0..100_000).map(|i| format!("line {i}\n")).collect();
318        let w = wrap(&body, 20);
319        // "line 50000" is 10 chars; at width 20 that's 1 row per line, so
320        // wrapped-row 50_000 should land exactly on line 50_000, col 0.
321        assert_eq!(w.row_col_to_textpos(50_000, 0), TextPos::new(50_000, 0));
322    }
323
324    #[test]
325    fn visible_window_only_wraps_the_requested_rows() {
326        let body: String = (0..1000).map(|i| format!("line {i}\n")).collect();
327        let w = wrap(&body, 20);
328        let rows = w.visible_window(500, 5);
329        assert_eq!(rows.len(), 5);
330        let text: Vec<String> = rows
331            .iter()
332            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
333            .collect();
334        assert_eq!(
335            text,
336            vec!["line 500", "line 501", "line 502", "line 503", "line 504"]
337        );
338    }
339
340    /// A single raw line with no newlines at all (e.g. a huge base64 blob or
341    /// minified JSON payload) must still produce a correct, small window
342    /// regardless of where the scroll offset falls inside it — and must do
343    /// so without ever wrapping the whole line (this used to cost O(line
344    /// length) per redraw and grind the app to a halt; see also the timing
345    /// regression test below).
346    #[test]
347    fn visible_window_is_correct_for_a_single_enormous_unbroken_line() {
348        let body: String = "abcdefghij".repeat(200_000); // 2,000,000 chars, one line
349        let w = wrap(&body, 10);
350
351        let top = w.visible_window(0, 3);
352        assert_eq!(top.len(), 3);
353        let row0: String = top[0].spans.iter().map(|s| s.content.as_ref()).collect();
354        assert_eq!(row0, "abcdefghij", "row 0 is chars [0, 10)");
355        let row2: String = top[2].spans.iter().map(|s| s.content.as_ref()).collect();
356        assert_eq!(
357            row2, "abcdefghij",
358            "row 2 (chars [20, 30)) lands mid-repeat but still aligned"
359        );
360
361        // Deep into the line: row 50_000 covers chars [500_000, 500_010).
362        let mid = w.visible_window(50_000, 2);
363        assert_eq!(mid.len(), 2);
364        let mid_row: String = mid[0].spans.iter().map(|s| s.content.as_ref()).collect();
365        assert_eq!(mid_row, "abcdefghij");
366
367        // Repeated calls with the same (scroll, height) hit the cache and
368        // must return identical content.
369        let again = w.visible_window(50_000, 2);
370        let again_text: Vec<String> = again
371            .iter()
372            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
373            .collect();
374        let mid_text: Vec<String> = mid
375            .iter()
376            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
377            .collect();
378        assert_eq!(again_text, mid_text);
379    }
380
381    /// Regression test for the reported "obscenely large response makes the
382    /// whole app grind to a halt" bug: a single multi-megabyte unwrapped
383    /// line used to cost O(line length) on *every single redraw* (both in
384    /// `visible_window`'s per-line `wrap_line` call and in
385    /// `PanelWrap::line_char_len`'s repeated `.chars().count()`), which
386    /// alone took >100ms per frame for a 5MB line. This asserts many
387    /// repeated redraws of such a line stay fast, with a bound generous
388    /// enough not to flake on slow CI hardware while still catching an
389    /// accidental return to O(line length)-per-frame behaviour.
390    #[test]
391    fn visible_window_stays_fast_across_many_redraws_of_a_single_huge_line() {
392        use std::time::{Duration, Instant};
393        let body: String = "x".repeat(5_000_000);
394        let w = wrap(&body, 78);
395
396        let start = Instant::now();
397        for _ in 0..200 {
398            let rows = w.visible_window(0, 30);
399            assert_eq!(
400                rows.len(),
401                30,
402                "the first 30 wrapped rows of a 5,000,000-char line at width 78"
403            );
404        }
405        let elapsed = start.elapsed();
406        assert!(
407            elapsed < Duration::from_secs(2),
408            "200 redraws of a single 5MB line took {elapsed:?} — expected a small fraction of a second"
409        );
410    }
411
412    #[test]
413    fn rebuild_if_needed_skips_rebuilding_on_an_unchanged_pointer_and_width() {
414        let source: Arc<str> = Arc::from("hello\nworld");
415        let mut cache: Option<PanelWrap> = None;
416        PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
417        let first_ptr = cache.as_ref().unwrap().source.as_ptr();
418        // Same Arc, same width -> must not rebuild (same backing pointer).
419        PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
420        assert_eq!(cache.as_ref().unwrap().source.as_ptr(), first_ptr);
421        // Width changed -> must rebuild.
422        PanelWrap::rebuild_if_needed(&mut cache, &source, 20);
423        assert_eq!(cache.as_ref().unwrap().width, 20);
424        // A genuinely new Arc (even with equal content) -> must rebuild too,
425        // since a new response/edit always allocates fresh.
426        let source2: Arc<str> = Arc::from("hello\nworld");
427        PanelWrap::rebuild_if_needed(&mut cache, &source2, 20);
428        assert!(Arc::ptr_eq(&cache.as_ref().unwrap().source, &source2));
429    }
430}