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::{Color, Modifier, Style};
19use ratatui::text::{Line, Span};
20
21use crate::wrap::{wrap_line, wrap_line_window, wrapped_row_count};
22
23/// A purely-visual end-of-row marker drawn in a reserved rightmost column on
24/// every *continued* wrapped row (a row that a raw line wrapped past — i.e.
25/// not the last row of that line), so a soft wrap is visually distinct from a
26/// real line break. Opt-in via [`SelectablePanel::set_wrap_marker`] /
27/// [`PanelWrap`]'s builders; only meaningful in [`WrapMode::Wrap`].
28///
29/// The marker occupies its own column: when enabled, lines wrap to one column
30/// narrower than the panel so the last column is free for the glyph. Because
31/// all selection and copy geometry keys off that reduced wrap width, the
32/// marker column never maps to a character — it is automatically excluded from
33/// highlighting and from copied text.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub struct WrapMarker {
36    /// The glyph drawn in the reserved column (e.g. a chevron `›` or a
37    /// return arrow `↵`). Must be a single terminal cell wide.
38    pub glyph: char,
39    /// The style the glyph is drawn with — typically a dim / greyed-out style
40    /// so it reads as an annotation rather than content.
41    pub style: Style,
42}
43
44impl Default for WrapMarker {
45    /// A dim, dark-grey return-arrow (`↵`) — a conventional soft-wrap
46    /// indicator. Override [`glyph`](Self::glyph) with a chevron (`›`) or any
47    /// other single-cell glyph, and [`style`](Self::style) to taste.
48    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/// Builder struct for `WrapMarker`
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub struct WraperMarkerBuilder {
65    /// The glyph drawn in the reserved column (e.g. a chevron `›` or a
66    /// return arrow `↵`). Must be a single terminal cell wide.
67    pub glyph: char,
68    /// The style the glyph is drawn with — typically a dim / greyed-out style
69    /// so it reads as an annotation rather than content.
70    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    /// Setter for `style``
82    pub fn style(mut self, style: Style) -> WraperMarkerBuilder {
83        self.style = style;
84        self
85    }
86
87    /// Setter for `glyph`
88    pub fn glyph(mut self, glyph: char) -> WraperMarkerBuilder {
89        self.glyph = glyph;
90        self
91    }
92}
93
94/// The width lines actually wrap to, given the panel's inner `width`, its
95/// layout `mode` and whether a [`WrapMarker`] is reserving a column. A marker
96/// steals the rightmost column (so `width - 1`), but only in [`WrapMode::Wrap`]
97/// and only when there's a spare column to give up (`width >= 2`); otherwise
98/// the full `width` is used.
99fn 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/// How a panel lays out raw lines wider than its inner width.
108#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
109pub enum WrapMode {
110    /// Break each raw line every `width` columns onto as many rows as needed
111    /// (the default). One raw line may occupy several screen rows.
112    #[default]
113    Wrap,
114    /// Render each raw line on exactly one screen row, clipping anything past
115    /// the panel's right edge — no wrapping and no horizontal scroll. One raw
116    /// line always maps to exactly one row, which is what a panel that
117    /// displays pre-formatted, column-aligned output (e.g. program output
118    /// echoed verbatim) wants.
119    Clip,
120}
121
122/// A position in a panel's logical (unwrapped) text: which raw line
123/// (0-based), and which character offset within it (0-based; may equal the
124/// line's own length to mean "just past its last character"). Deliberately
125/// never a screen/terminal coordinate, so it stays valid across rewraps.
126#[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
138/// Per-line style runs `(char_from, char_to_exclusive, style)`, one inner
139/// `Vec` per raw line, aligned to the plain text's characters. Only populated
140/// for ANSI content (the `ansi` feature); `None` means "render unstyled".
141type LineStyles = Vec<Vec<(usize, usize, Style)>>;
142
143/// Exclusive prefix sum of wrapped-row counts across a panel's raw lines:
144/// `cum[i]` = total wrapped rows in lines `0..i`. `cum.len() == line_count +
145/// 1`; `*cum.last()` is the grand total (0 for no lines at all). Also caches
146/// each line's own character length (`lens`) — computed once here, from the
147/// same pass that already has to walk every line to determine wrapped-row
148/// counts — so `PanelWrap::line_char_len` never has to re-scan a line's
149/// characters itself (an O(1) selection/highlight primitive, even for a
150/// single enormous line).
151struct 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                // Clip mode collapses every raw line onto a single row.
165                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    /// The raw line index and row-offset-within-that-line for absolute
183    /// wrapped row `row`, found by binary search (not a linear scan) so
184    /// locating a scroll position deep into a huge body stays cheap.
185    fn locate(&self, row: u32) -> (usize, u32) {
186        if self.cum.len() <= 1 {
187            return (0, 0);
188        }
189        // First index whose cumulative count exceeds `row`; the line just
190        // before it is the one containing `row`.
191        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
197/// Cached line/wrap structure for one panel's text, rebuilt only when its
198/// content or width actually changes (see [`PanelWrap::rebuild_if_needed`]).
199pub struct PanelWrap {
200    /// The exact text (or, in ANSI mode, the raw text *with* escape
201    /// sequences) this cache was built from — kept for a cheap `Arc::ptr_eq`
202    /// "has the content changed?" check. In plain mode this is the same `Arc`
203    /// as [`source`](Self::source); in ANSI mode it's the un-stripped input.
204    raw: Arc<str>,
205    /// The plain (ANSI-stripped) text that all geometry, selection and copy
206    /// operate on — kept alive so `line_ranges` (byte offsets into it) stay
207    /// valid.
208    source: Arc<str>,
209    /// Byte (start, end) of each raw line within `source` (split on '\n',
210    /// stripping a trailing '\r', matching `str::lines()`).
211    line_ranges: Vec<(usize, usize)>,
212    rows: LineRows,
213    width: usize,
214    /// The width lines actually wrap to — `width`, less one column when a
215    /// [`WrapMarker`] reserves the rightmost column (see
216    /// [`effective_wrap_width`]). All geometry, selection and highlight math
217    /// use this, never the raw panel `width`, so the reserved marker column is
218    /// consistently excluded.
219    wrap_width: usize,
220    mode: WrapMode,
221    /// The end-of-row wrap marker, if enabled. Purely a rendering concern
222    /// (geometry only cares *whether* a column is reserved, via `wrap_width`).
223    marker: Option<WrapMarker>,
224    /// Per-line style runs `(char_from, char_to_exclusive, style)` for ANSI
225    /// content, aligned to `source`'s characters; `None` for plain text
226    /// (rendered without styling). Only ever populated via the `ansi`
227    /// feature.
228    line_styles: Option<LineStyles>,
229    /// The last `visible_window` result, keyed by the `(scroll, height)` it
230    /// was computed for. Most frames redraw with an unchanged scroll
231    /// position, so this turns those into an O(1) clone of a handful of
232    /// already-wrapped rows instead of re-wrapping anything — no per-frame
233    /// work proportional to content size, no matter how large the body or
234    /// how long an individual line is.
235    last_window: RefCell<Option<(u16, u16, Vec<Line<'static>>)>>,
236}
237
238impl PanelWrap {
239    /// Build fresh from `source` at `width` columns, wrapping long lines
240    /// ([`WrapMode::Wrap`]). O(source length) — call only when content/width
241    /// has actually changed (see `rebuild_if_needed`), never unconditionally
242    /// on every frame.
243    pub fn build(source: Arc<str>, width: usize) -> Self {
244        Self::build_with(source, width, WrapMode::Wrap)
245    }
246
247    /// Build fresh from plain `source` with an explicit [`WrapMode`].
248    pub fn build_with(source: Arc<str>, width: usize, mode: WrapMode) -> Self {
249        Self::build_with_marker(source, width, mode, None)
250    }
251
252    /// Build fresh from plain `source` with an explicit [`WrapMode`] and an
253    /// optional end-of-row [`WrapMarker`] (which reserves the rightmost
254    /// column, narrowing the wrap width by one).
255    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    /// Split `source` into raw-line byte ranges (on '\n', dropping a trailing
285    /// '\r'), matching `str::lines()`.
286    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    /// Build fresh from ANSI-coloured `raw` with an explicit [`WrapMode`]. The
307    /// escape sequences are parsed once into per-line style runs; all
308    /// geometry, selection and copy operate on the plain, stripped text, so
309    /// colour is purely a rendering concern. Requires the `ansi` feature.
310    #[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    /// Build fresh from ANSI-coloured `raw` with an explicit [`WrapMode`] and
316    /// an optional end-of-row [`WrapMarker`]. Requires the `ansi` feature.
317    #[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        // Byte ranges built directly from the plain lines we just produced, so
328        // they stay exactly aligned with `styles` (one entry per line).
329        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; // skip the '\n' the join inserts
336        }
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    /// Build fresh from pre-styled ratatui [`Line`]s — e.g. syntax-highlighted
360    /// source or a diff — wrapping long lines ([`WrapMode::Wrap`]). Each span's
361    /// style is recorded as a per-line style run, exactly like the ANSI path,
362    /// but taking styled [`Line`]s directly instead of parsing escape
363    /// sequences (so no `ansi` feature is required). All geometry, selection
364    /// and copy operate on the plain, concatenated text; styling is purely a
365    /// rendering concern.
366    ///
367    /// The input `Line`s are consumed only to extract their text and styles —
368    /// they are not retained — so any lifetime is accepted. As styled content
369    /// is typically recomputed each frame (there is no stable `Arc` identity to
370    /// diff against), call this only when the content actually changed.
371    pub fn build_styled(lines: &[Line<'_>], width: usize) -> Self {
372        Self::build_styled_with_marker(lines, width, WrapMode::Wrap, None)
373    }
374
375    /// Like [`build_styled`](Self::build_styled) but with an explicit
376    /// [`WrapMode`] and an optional end-of-row [`WrapMarker`].
377    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        // Match the plain/ANSI paths: at least one (empty) line so geometry is
403        // always well-defined.
404        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        // Byte ranges built directly from the plain lines we just produced, so
410        // they stay exactly aligned with `styles` (one entry per line).
411        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; // skip the '\n' the join inserts
418        }
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    /// Rebuild only if `source`'s identity (by pointer — a new response/edit
439    /// always produces a fresh allocation) or `width` differ from what's
440    /// cached; otherwise this is a no-op, keeping repeated frames (drags,
441    /// idle redraws) cheap regardless of how large the content is. Plain
442    /// text, [`WrapMode::Wrap`].
443    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    /// Like [`rebuild_if_needed`](Self::rebuild_if_needed) but for plain text
448    /// with an explicit [`WrapMode`]. Also rebuilds if the mode changed, or if
449    /// the cache currently holds ANSI-styled content.
450    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    /// Like [`rebuild_if_needed_with`](Self::rebuild_if_needed_with) but also
460    /// carrying an optional end-of-row [`WrapMarker`]. Rebuilds if the marker
461    /// changed (it affects the reserved column and thus the wrap geometry).
462    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    /// Like [`rebuild_if_needed_with`](Self::rebuild_if_needed_with) but for
490    /// ANSI-coloured content. Also rebuilds if the mode changed, or if the
491    /// cache currently holds plain content. Requires the `ansi` feature.
492    #[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    /// Like [`rebuild_if_needed_ansi`](Self::rebuild_if_needed_ansi) but also
503    /// carrying an optional end-of-row [`WrapMarker`]. Requires the `ansi`
504    /// feature.
505    #[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    /// This panel's line-layout mode.
534    pub fn mode(&self) -> WrapMode {
535        self.mode
536    }
537
538    /// The width lines actually wrap to — the panel's inner width, less one
539    /// column when a [`WrapMarker`] reserves the rightmost column. Selection
540    /// and highlight geometry must use this, not the raw panel width, so the
541    /// reserved marker column is excluded.
542    pub fn wrap_width(&self) -> usize {
543        self.wrap_width
544    }
545
546    /// This panel's end-of-row wrap marker, if any.
547    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    /// The exact, unmodified text this cache was built from — every line,
556    /// with its original line endings, not just what's currently scrolled
557    /// into view. Used for "copy the whole panel" (no selection needed).
558    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    /// The exact wrapped rows visible in a `height`-row window starting at
576    /// absolute wrapped-row `scroll` — the only rows actually wrapped, and
577    /// only the portion of each raw line that window actually covers
578    /// (`wrap_line_window`), regardless of the total content size or how
579    /// long any single raw line is. Repeated calls with the same
580    /// `(scroll, height)` (the common case across idle/unchanged frames)
581    /// hit `last_window` and do no wrapping work at all.
582    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    /// [`WrapMode::Wrap`] window: wrap only the rows actually on screen.
601    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            // Annotate every *continued* row of this line (any row but its
617            // last) with the end-of-row wrap marker. `skip` is the first
618            // row-in-line this window covers, so the k-th produced row is
619            // row-in-line `skip + k`; it is continued when another row of the
620            // same line follows it.
621            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    /// Append the [`WrapMarker`] glyph to each row of `rows` that is a
630    /// *continued* wrapped row of raw line `idx` — i.e. not that line's last
631    /// row. `first_row` is the row-in-line index the first element of `rows`
632    /// corresponds to. A no-op when no marker is configured or no column was
633    /// actually reserved for it (a too-narrow panel).
634    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        // Only draw when a column was genuinely reserved (see
639        // `effective_wrap_width`); otherwise there is nowhere to put the glyph
640        // without overwriting content.
641        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    /// [`WrapMode::Clip`] window: one row per raw line, each clipped to
655    /// `width` characters (so a single enormous line still costs only what's
656    /// on screen).
657    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    /// Wrap only a bounded window of a *styled* line: skip `skip_rows` whole
672    /// wrapped rows, then wrap at most `max_rows` more — without materialising
673    /// the rest of the line (the styled counterpart of `wrap_line_window`).
674    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    /// The styled spans for characters `[c0, c1)` of raw line `idx`. Plain
704    /// content yields a single unstyled span; ANSI content splits the slice at
705    /// its style-run boundaries so each run keeps its colour.
706    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    /// Convert a logical [`TextPos`] into its absolute wrapped-row index and
745    /// column-within-that-row — the reverse of [`Self::row_col_to_textpos`],
746    /// used to project a (resize-invariant) selection back onto the current
747    /// frame's screen space for highlighting or scroll-into-view.
748    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        // In clip mode every raw line is exactly one row, so the row is just
756        // the line's cumulative index and the column maps straight through.
757        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    /// Convert an absolute wrapped-row index + column-in-row (screen space)
767    /// into the logical [`TextPos`] it corresponds to — the reverse of
768    /// [`Self::textpos_to_row_col`], used to map a mouse click/drag onto
769    /// real content.
770    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        // `col` may be `usize::MAX` (callers use this to mean "clamp to the
782        // end of the line", e.g. auto-scroll snapping the selection cursor
783        // to a row's last character) — add with saturation so that intent
784        // doesn't overflow before the `.min(len)` clamp gets a chance to
785        // apply.
786        TextPos::new(line, base.saturating_add(col).min(len))
787    }
788}
789
790/// Parse ANSI-coloured `raw` into per-line plain text plus per-line style runs
791/// `(char_from, char_to_exclusive, style)`. The two are produced in one pass so
792/// they stay exactly aligned character-for-character.
793#[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        // A trailing carriage return belongs to the line ending, not the line
818        // (matching `str::lines()`); drop it and clamp the last run.
819        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        // Two lines, each far wider than the width; clip keeps them 1 row each.
858        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        // Row 0 is the (clipped) first 10 chars of the long line; row 1 is the
862        // whole short line.
863        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        // Wrapped row == line index; column maps 1:1 (no wrap offset).
873        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        // A column past the clip width still resolves to the same line.
876        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); // width 4 clips "line N" to "line"
883        let rows = w.visible_window(500, 3);
884        assert_eq!(rows.len(), 3);
885        assert_eq!(row_text(&rows[0]), "line");
886        // Each visible line is clipped to 4 chars but still one row per line.
887        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        // "0123456789ABCDE" (15 chars) at width 10 -> 2 rows; "" -> 1 row.
916        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); // rows 0: "0123456789", row 1: "ABCDE"
923        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        // A position exactly at the line's own length (cursor "past the end").
928        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        // "line 50000" is 10 chars; at width 20 that's 1 row per line, so
936        // wrapped-row 50_000 should land exactly on line 50_000, col 0.
937        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    /// A single raw line with no newlines at all (e.g. a huge base64 blob or
957    /// minified JSON payload) must still produce a correct, small window
958    /// regardless of where the scroll offset falls inside it — and must do
959    /// so without ever wrapping the whole line (this used to cost O(line
960    /// length) per redraw and grind the app to a halt; see also the timing
961    /// regression test below).
962    #[test]
963    fn visible_window_is_correct_for_a_single_enormous_unbroken_line() {
964        let body: String = "abcdefghij".repeat(200_000); // 2,000,000 chars, one line
965        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        // Deep into the line: row 50_000 covers chars [500_000, 500_010).
978        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        // Repeated calls with the same (scroll, height) hit the cache and
984        // must return identical content.
985        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    /// Regression test for the reported "obscenely large response makes the
998    /// whole app grind to a halt" bug: a single multi-megabyte unwrapped
999    /// line used to cost O(line length) on *every single redraw* (both in
1000    /// `visible_window`'s per-line `wrap_line` call and in
1001    /// `PanelWrap::line_char_len`'s repeated `.chars().count()`), which
1002    /// alone took >100ms per frame for a 5MB line. This asserts many
1003    /// repeated redraws of such a line stay fast, with a bound generous
1004    /// enough not to flake on slow CI hardware while still catching an
1005    /// accidental return to O(line length)-per-frame behaviour.
1006    #[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        // Same Arc, same width -> must not rebuild (same backing pointer).
1035        PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
1036        assert_eq!(cache.as_ref().unwrap().source.as_ptr(), first_ptr);
1037        // Width changed -> must rebuild.
1038        PanelWrap::rebuild_if_needed(&mut cache, &source, 20);
1039        assert_eq!(cache.as_ref().unwrap().width, 20);
1040        // A genuinely new Arc (even with equal content) -> must rebuild too,
1041        // since a new response/edit always allocates fresh.
1042        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        // Two styled logical lines; spans carry colour that must survive.
1051        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        // Geometry/copy see the plain concatenated text, not the styling.
1063        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        // Rendered rows keep their per-span colour.
1068        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        // "greenlong" (9 chars) all one colour, wrapped at width 4 -> 3 rows.
1080        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        // Selection/geometry see the plain text, not the escape sequences.
1109        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        // First span is the red "red"; the rest is unstyled " plain".
1121        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        // "red" (3) + " plain" (6) = 9 chars; width 4 wraps to 3 rows.
1138        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        // The 'd' at the wrap boundary keeps the red colour.
1143        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        // Same Arc + width + mode -> no rebuild.
1164        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        // Switching to the plain builder must rebuild (styled -> unstyled).
1168        PanelWrap::rebuild_if_needed_with(&mut cache, &raw, 40, WrapMode::Wrap);
1169        assert!(cache.as_ref().unwrap().line_styles.is_none());
1170    }
1171}