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    /// Rebuild only if `source`'s identity (by pointer — a new response/edit
360    /// always produces a fresh allocation) or `width` differ from what's
361    /// cached; otherwise this is a no-op, keeping repeated frames (drags,
362    /// idle redraws) cheap regardless of how large the content is. Plain
363    /// text, [`WrapMode::Wrap`].
364    pub fn rebuild_if_needed(cache: &mut Option<PanelWrap>, source: &Arc<str>, width: usize) {
365        Self::rebuild_if_needed_with(cache, source, width, WrapMode::Wrap);
366    }
367
368    /// Like [`rebuild_if_needed`](Self::rebuild_if_needed) but for plain text
369    /// with an explicit [`WrapMode`]. Also rebuilds if the mode changed, or if
370    /// the cache currently holds ANSI-styled content.
371    pub fn rebuild_if_needed_with(
372        cache: &mut Option<PanelWrap>,
373        source: &Arc<str>,
374        width: usize,
375        mode: WrapMode,
376    ) {
377        Self::rebuild_if_needed_marker(cache, source, width, mode, None);
378    }
379
380    /// Like [`rebuild_if_needed_with`](Self::rebuild_if_needed_with) but also
381    /// carrying an optional end-of-row [`WrapMarker`]. Rebuilds if the marker
382    /// changed (it affects the reserved column and thus the wrap geometry).
383    pub fn rebuild_if_needed_marker(
384        cache: &mut Option<PanelWrap>,
385        source: &Arc<str>,
386        width: usize,
387        mode: WrapMode,
388        marker: Option<WrapMarker>,
389    ) {
390        let stale = match cache {
391            Some(c) => {
392                !Arc::ptr_eq(&c.raw, source)
393                    || c.width != width
394                    || c.mode != mode
395                    || c.marker != marker
396                    || c.line_styles.is_some()
397            }
398            None => true,
399        };
400        if stale {
401            *cache = Some(PanelWrap::build_with_marker(
402                Arc::clone(source),
403                width,
404                mode,
405                marker,
406            ));
407        }
408    }
409
410    /// Like [`rebuild_if_needed_with`](Self::rebuild_if_needed_with) but for
411    /// ANSI-coloured content. Also rebuilds if the mode changed, or if the
412    /// cache currently holds plain content. Requires the `ansi` feature.
413    #[cfg(feature = "ansi")]
414    pub fn rebuild_if_needed_ansi(
415        cache: &mut Option<PanelWrap>,
416        raw: &Arc<str>,
417        width: usize,
418        mode: WrapMode,
419    ) {
420        Self::rebuild_if_needed_ansi_marker(cache, raw, width, mode, None);
421    }
422
423    /// Like [`rebuild_if_needed_ansi`](Self::rebuild_if_needed_ansi) but also
424    /// carrying an optional end-of-row [`WrapMarker`]. Requires the `ansi`
425    /// feature.
426    #[cfg(feature = "ansi")]
427    pub fn rebuild_if_needed_ansi_marker(
428        cache: &mut Option<PanelWrap>,
429        raw: &Arc<str>,
430        width: usize,
431        mode: WrapMode,
432        marker: Option<WrapMarker>,
433    ) {
434        let stale = match cache {
435            Some(c) => {
436                !Arc::ptr_eq(&c.raw, raw)
437                    || c.width != width
438                    || c.mode != mode
439                    || c.marker != marker
440                    || c.line_styles.is_none()
441            }
442            None => true,
443        };
444        if stale {
445            *cache = Some(PanelWrap::build_ansi_with_marker(
446                Arc::clone(raw),
447                width,
448                mode,
449                marker,
450            ));
451        }
452    }
453
454    /// This panel's line-layout mode.
455    pub fn mode(&self) -> WrapMode {
456        self.mode
457    }
458
459    /// The width lines actually wrap to — the panel's inner width, less one
460    /// column when a [`WrapMarker`] reserves the rightmost column. Selection
461    /// and highlight geometry must use this, not the raw panel width, so the
462    /// reserved marker column is excluded.
463    pub fn wrap_width(&self) -> usize {
464        self.wrap_width
465    }
466
467    /// This panel's end-of-row wrap marker, if any.
468    pub fn marker(&self) -> Option<WrapMarker> {
469        self.marker
470    }
471
472    pub fn line_count(&self) -> usize {
473        self.rows.line_count()
474    }
475
476    /// The exact, unmodified text this cache was built from — every line,
477    /// with its original line endings, not just what's currently scrolled
478    /// into view. Used for "copy the whole panel" (no selection needed).
479    pub fn source(&self) -> &str {
480        &self.source
481    }
482
483    pub fn line_text(&self, idx: usize) -> &str {
484        let (s, e) = self.line_ranges[idx];
485        &self.source[s..e]
486    }
487
488    pub fn line_char_len(&self, idx: usize) -> usize {
489        self.rows.lens.get(idx).copied().unwrap_or(0)
490    }
491
492    pub fn total_rows(&self) -> u32 {
493        self.rows.total_rows()
494    }
495
496    /// The exact wrapped rows visible in a `height`-row window starting at
497    /// absolute wrapped-row `scroll` — the only rows actually wrapped, and
498    /// only the portion of each raw line that window actually covers
499    /// (`wrap_line_window`), regardless of the total content size or how
500    /// long any single raw line is. Repeated calls with the same
501    /// `(scroll, height)` (the common case across idle/unchanged frames)
502    /// hit `last_window` and do no wrapping work at all.
503    pub fn visible_window(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
504        if height == 0 || self.line_count() == 0 {
505            return Vec::new();
506        }
507        if let Some((cached_scroll, cached_height, cached)) = self.last_window.borrow().as_ref()
508            && *cached_scroll == scroll
509            && *cached_height == height
510        {
511            return cached.clone();
512        }
513        let out = match self.mode {
514            WrapMode::Clip => self.visible_window_clip(scroll, height),
515            WrapMode::Wrap => self.visible_window_wrap(scroll, height),
516        };
517        *self.last_window.borrow_mut() = Some((scroll, height, out.clone()));
518        out
519    }
520
521    /// [`WrapMode::Wrap`] window: wrap only the rows actually on screen.
522    fn visible_window_wrap(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
523        let (start_line, row_in_line) = self.rows.locate(scroll as u32);
524        let height_usize = height as usize;
525        let mut out: Vec<Line<'static>> = Vec::with_capacity(height_usize);
526        let mut skip = row_in_line as usize;
527        for idx in start_line..self.line_count() {
528            if out.len() >= height_usize {
529                break;
530            }
531            let budget = height_usize - out.len();
532            let mut rows = if self.line_styles.is_none() {
533                wrap_line_window(self.line_text(idx), self.wrap_width, skip, budget)
534            } else {
535                self.wrap_line_window_styled(idx, skip, budget)
536            };
537            // Annotate every *continued* row of this line (any row but its
538            // last) with the end-of-row wrap marker. `skip` is the first
539            // row-in-line this window covers, so the k-th produced row is
540            // row-in-line `skip + k`; it is continued when another row of the
541            // same line follows it.
542            self.mark_continued_rows(idx, skip, &mut rows);
543            out.extend(rows);
544            skip = 0;
545        }
546        out.truncate(height_usize);
547        out
548    }
549
550    /// Append the [`WrapMarker`] glyph to each row of `rows` that is a
551    /// *continued* wrapped row of raw line `idx` — i.e. not that line's last
552    /// row. `first_row` is the row-in-line index the first element of `rows`
553    /// corresponds to. A no-op when no marker is configured or no column was
554    /// actually reserved for it (a too-narrow panel).
555    fn mark_continued_rows(&self, idx: usize, first_row: usize, rows: &mut [Line<'static>]) {
556        let Some(marker) = self.marker else {
557            return;
558        };
559        // Only draw when a column was genuinely reserved (see
560        // `effective_wrap_width`); otherwise there is nowhere to put the glyph
561        // without overwriting content.
562        if self.wrap_width >= self.width {
563            return;
564        }
565        let total_in_line = wrapped_row_count(self.line_char_len(idx), self.wrap_width);
566        for (k, line) in rows.iter_mut().enumerate() {
567            let row_in_line = first_row + k;
568            if row_in_line + 1 < total_in_line {
569                line.spans
570                    .push(Span::styled(marker.glyph.to_string(), marker.style));
571            }
572        }
573    }
574
575    /// [`WrapMode::Clip`] window: one row per raw line, each clipped to
576    /// `width` characters (so a single enormous line still costs only what's
577    /// on screen).
578    fn visible_window_clip(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
579        let start = scroll as usize;
580        let height_usize = height as usize;
581        let mut out: Vec<Line<'static>> = Vec::with_capacity(height_usize);
582        for idx in start..self.line_count() {
583            if out.len() >= height_usize {
584                break;
585            }
586            let end = self.line_char_len(idx).min(self.width);
587            out.push(Line::from(self.styled_spans(idx, 0, end)));
588        }
589        out
590    }
591
592    /// Wrap only a bounded window of a *styled* line: skip `skip_rows` whole
593    /// wrapped rows, then wrap at most `max_rows` more — without materialising
594    /// the rest of the line (the styled counterpart of `wrap_line_window`).
595    fn wrap_line_window_styled(
596        &self,
597        idx: usize,
598        skip_rows: usize,
599        max_rows: usize,
600    ) -> Vec<Line<'static>> {
601        if max_rows == 0 {
602            return Vec::new();
603        }
604        if self.wrap_width == 0 {
605            return if skip_rows == 0 {
606                vec![Line::from(self.styled_spans(
607                    idx,
608                    0,
609                    self.line_char_len(idx),
610                ))]
611            } else {
612                Vec::new()
613            };
614        }
615        let c0 = skip_rows.saturating_mul(self.wrap_width);
616        let c1 = c0.saturating_add(max_rows.saturating_mul(self.wrap_width));
617        let spans = self.styled_spans(idx, c0, c1);
618        if spans.is_empty() {
619            return Vec::new();
620        }
621        wrap_line(Line::from(spans), self.wrap_width)
622    }
623
624    /// The styled spans for characters `[c0, c1)` of raw line `idx`. Plain
625    /// content yields a single unstyled span; ANSI content splits the slice at
626    /// its style-run boundaries so each run keeps its colour.
627    fn styled_spans(&self, idx: usize, c0: usize, c1: usize) -> Vec<Span<'static>> {
628        if c1 <= c0 {
629            return Vec::new();
630        }
631        let text = self.line_text(idx);
632        let slice: String = text.chars().skip(c0).take(c1 - c0).collect();
633        if slice.is_empty() {
634            return Vec::new();
635        }
636        let runs = match &self.line_styles {
637            None => return vec![Span::raw(slice)],
638            Some(all) => all.get(idx).map(|v| v.as_slice()).unwrap_or(&[]),
639        };
640        if runs.is_empty() {
641            return vec![Span::raw(slice)];
642        }
643        let style_at = |abs: usize| {
644            runs.iter()
645                .find(|&&(s, e, _)| abs >= s && abs < e)
646                .map(|&(_, _, st)| st)
647                .unwrap_or_default()
648        };
649        let chars: Vec<char> = slice.chars().collect();
650        let mut spans = Vec::new();
651        let mut i = 0usize;
652        while i < chars.len() {
653            let style = style_at(c0 + i);
654            let mut j = i + 1;
655            while j < chars.len() && style_at(c0 + j) == style {
656                j += 1;
657            }
658            let seg: String = chars[i..j].iter().collect();
659            spans.push(Span::styled(seg, style));
660            i = j;
661        }
662        spans
663    }
664
665    /// Convert a logical [`TextPos`] into its absolute wrapped-row index and
666    /// column-within-that-row — the reverse of [`Self::row_col_to_textpos`],
667    /// used to project a (resize-invariant) selection back onto the current
668    /// frame's screen space for highlighting or scroll-into-view.
669    pub fn textpos_to_row_col(&self, pos: TextPos) -> (u32, usize) {
670        if self.line_count() == 0 {
671            return (0, 0);
672        }
673        let line = pos.line.min(self.line_count() - 1);
674        let len = self.line_char_len(line);
675        let col = pos.col.min(len);
676        // In clip mode every raw line is exactly one row, so the row is just
677        // the line's cumulative index and the column maps straight through.
678        if self.mode == WrapMode::Clip || self.wrap_width == 0 {
679            return (self.rows.cum[line], col);
680        }
681        let rows_in_line = wrapped_row_count(len, self.wrap_width) as u32;
682        let row_in_line = ((col / self.wrap_width) as u32).min(rows_in_line.saturating_sub(1));
683        let col_in_row = col.saturating_sub(row_in_line as usize * self.wrap_width);
684        (self.rows.cum[line] + row_in_line, col_in_row)
685    }
686
687    /// Convert an absolute wrapped-row index + column-in-row (screen space)
688    /// into the logical [`TextPos`] it corresponds to — the reverse of
689    /// [`Self::textpos_to_row_col`], used to map a mouse click/drag onto
690    /// real content.
691    pub fn row_col_to_textpos(&self, row: u32, col: usize) -> TextPos {
692        if self.line_count() == 0 {
693            return TextPos::new(0, 0);
694        }
695        let (line, row_in_line) = self.rows.locate(row);
696        let len = self.line_char_len(line);
697        let base = if self.wrap_width == 0 {
698            0
699        } else {
700            row_in_line as usize * self.wrap_width
701        };
702        // `col` may be `usize::MAX` (callers use this to mean "clamp to the
703        // end of the line", e.g. auto-scroll snapping the selection cursor
704        // to a row's last character) — add with saturation so that intent
705        // doesn't overflow before the `.min(len)` clamp gets a chance to
706        // apply.
707        TextPos::new(line, base.saturating_add(col).min(len))
708    }
709}
710
711/// Parse ANSI-coloured `raw` into per-line plain text plus per-line style runs
712/// `(char_from, char_to_exclusive, style)`. The two are produced in one pass so
713/// they stay exactly aligned character-for-character.
714#[cfg(feature = "ansi")]
715fn parse_ansi(raw: &str) -> (Vec<String>, LineStyles) {
716    use ansi_to_tui::IntoText;
717    use ratatui::text::Text;
718
719    let text = raw
720        .into_text()
721        .unwrap_or_else(|_| Text::raw(raw.to_string()));
722    let mut plain_lines: Vec<String> = Vec::with_capacity(text.lines.len().max(1));
723    let mut styles: LineStyles = Vec::with_capacity(text.lines.len().max(1));
724    for line in &text.lines {
725        let mut plain = String::new();
726        let mut runs: Vec<(usize, usize, Style)> = Vec::new();
727        let mut col = 0usize;
728        for span in &line.spans {
729            let content: &str = span.content.as_ref();
730            let n = content.chars().count();
731            if n == 0 {
732                continue;
733            }
734            runs.push((col, col + n, line.style.patch(span.style)));
735            plain.push_str(content);
736            col += n;
737        }
738        // A trailing carriage return belongs to the line ending, not the line
739        // (matching `str::lines()`); drop it and clamp the last run.
740        if plain.ends_with('\r') {
741            plain.pop();
742            let new_len = plain.chars().count();
743            if let Some(last) = runs.last_mut() {
744                last.1 = last.1.min(new_len);
745                if last.0 >= last.1 {
746                    runs.pop();
747                }
748            }
749        }
750        plain_lines.push(plain);
751        styles.push(runs);
752    }
753    if plain_lines.is_empty() {
754        plain_lines.push(String::new());
755        styles.push(Vec::new());
756    }
757    (plain_lines, styles)
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    fn wrap(text: &str, width: usize) -> PanelWrap {
765        PanelWrap::build(Arc::from(text), width)
766    }
767
768    fn clip(text: &str, width: usize) -> PanelWrap {
769        PanelWrap::build_with(Arc::from(text), width, WrapMode::Clip)
770    }
771
772    fn row_text(line: &Line<'static>) -> String {
773        line.spans.iter().map(|s| s.content.as_ref()).collect()
774    }
775
776    #[test]
777    fn clip_mode_maps_one_row_per_line_regardless_of_length() {
778        // Two lines, each far wider than the width; clip keeps them 1 row each.
779        let w = clip("0123456789ABCDE\nshort", 10);
780        assert_eq!(w.line_count(), 2);
781        assert_eq!(w.total_rows(), 2, "one row per raw line, no wrapping");
782        // Row 0 is the (clipped) first 10 chars of the long line; row 1 is the
783        // whole short line.
784        let rows = w.visible_window(0, 5);
785        assert_eq!(rows.len(), 2);
786        assert_eq!(row_text(&rows[0]), "0123456789", "clipped to width");
787        assert_eq!(row_text(&rows[1]), "short");
788    }
789
790    #[test]
791    fn clip_mode_row_and_textpos_map_straight_through() {
792        let w = clip("0123456789ABCDE\nsecond", 10);
793        // Wrapped row == line index; column maps 1:1 (no wrap offset).
794        assert_eq!(w.textpos_to_row_col(TextPos::new(1, 3)), (1, 3));
795        assert_eq!(w.row_col_to_textpos(1, 3), TextPos::new(1, 3));
796        // A column past the clip width still resolves to the same line.
797        assert_eq!(w.row_col_to_textpos(0, 4), TextPos::new(0, 4));
798    }
799
800    #[test]
801    fn clip_mode_scrolls_by_whole_lines() {
802        let body: String = (0..1000).map(|i| format!("line {i}\n")).collect();
803        let w = clip(&body, 4); // width 4 clips "line N" to "line"
804        let rows = w.visible_window(500, 3);
805        assert_eq!(rows.len(), 3);
806        assert_eq!(row_text(&rows[0]), "line");
807        // Each visible line is clipped to 4 chars but still one row per line.
808        assert_eq!(w.total_rows(), 1000);
809    }
810
811    #[test]
812    fn splits_lines_like_str_lines_including_trailing_newline_and_crlf() {
813        let w = wrap("a\r\nb\nc", 10);
814        assert_eq!(w.line_count(), 3);
815        assert_eq!(w.line_text(0), "a");
816        assert_eq!(w.line_text(1), "b");
817        assert_eq!(w.line_text(2), "c");
818
819        let w2 = wrap("a\nb\n", 10);
820        assert_eq!(
821            w2.line_count(),
822            2,
823            "no trailing empty line after a final \\n, matching str::lines()"
824        );
825    }
826
827    #[test]
828    fn empty_body_has_one_line_and_one_row() {
829        let w = wrap("", 10);
830        assert_eq!(w.line_count(), 1);
831        assert_eq!(w.total_rows(), 1);
832    }
833
834    #[test]
835    fn total_rows_accounts_for_wrapping_long_lines() {
836        // "0123456789ABCDE" (15 chars) at width 10 -> 2 rows; "" -> 1 row.
837        let w = wrap("0123456789ABCDE\n", 10);
838        assert_eq!(w.total_rows(), 2);
839    }
840
841    #[test]
842    fn row_col_and_textpos_roundtrip_for_a_wrapped_line() {
843        let w = wrap("0123456789ABCDE", 10); // rows 0: "0123456789", row 1: "ABCDE"
844        assert_eq!(w.row_col_to_textpos(0, 3), TextPos::new(0, 3));
845        assert_eq!(w.row_col_to_textpos(1, 2), TextPos::new(0, 12));
846        assert_eq!(w.textpos_to_row_col(TextPos::new(0, 3)), (0, 3));
847        assert_eq!(w.textpos_to_row_col(TextPos::new(0, 12)), (1, 2));
848        // A position exactly at the line's own length (cursor "past the end").
849        assert_eq!(w.textpos_to_row_col(TextPos::new(0, 15)), (1, 5));
850    }
851
852    #[test]
853    fn locate_binary_search_finds_the_right_line_for_a_huge_body() {
854        let body: String = (0..100_000).map(|i| format!("line {i}\n")).collect();
855        let w = wrap(&body, 20);
856        // "line 50000" is 10 chars; at width 20 that's 1 row per line, so
857        // wrapped-row 50_000 should land exactly on line 50_000, col 0.
858        assert_eq!(w.row_col_to_textpos(50_000, 0), TextPos::new(50_000, 0));
859    }
860
861    #[test]
862    fn visible_window_only_wraps_the_requested_rows() {
863        let body: String = (0..1000).map(|i| format!("line {i}\n")).collect();
864        let w = wrap(&body, 20);
865        let rows = w.visible_window(500, 5);
866        assert_eq!(rows.len(), 5);
867        let text: Vec<String> = rows
868            .iter()
869            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
870            .collect();
871        assert_eq!(
872            text,
873            vec!["line 500", "line 501", "line 502", "line 503", "line 504"]
874        );
875    }
876
877    /// A single raw line with no newlines at all (e.g. a huge base64 blob or
878    /// minified JSON payload) must still produce a correct, small window
879    /// regardless of where the scroll offset falls inside it — and must do
880    /// so without ever wrapping the whole line (this used to cost O(line
881    /// length) per redraw and grind the app to a halt; see also the timing
882    /// regression test below).
883    #[test]
884    fn visible_window_is_correct_for_a_single_enormous_unbroken_line() {
885        let body: String = "abcdefghij".repeat(200_000); // 2,000,000 chars, one line
886        let w = wrap(&body, 10);
887
888        let top = w.visible_window(0, 3);
889        assert_eq!(top.len(), 3);
890        let row0: String = top[0].spans.iter().map(|s| s.content.as_ref()).collect();
891        assert_eq!(row0, "abcdefghij", "row 0 is chars [0, 10)");
892        let row2: String = top[2].spans.iter().map(|s| s.content.as_ref()).collect();
893        assert_eq!(
894            row2, "abcdefghij",
895            "row 2 (chars [20, 30)) lands mid-repeat but still aligned"
896        );
897
898        // Deep into the line: row 50_000 covers chars [500_000, 500_010).
899        let mid = w.visible_window(50_000, 2);
900        assert_eq!(mid.len(), 2);
901        let mid_row: String = mid[0].spans.iter().map(|s| s.content.as_ref()).collect();
902        assert_eq!(mid_row, "abcdefghij");
903
904        // Repeated calls with the same (scroll, height) hit the cache and
905        // must return identical content.
906        let again = w.visible_window(50_000, 2);
907        let again_text: Vec<String> = again
908            .iter()
909            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
910            .collect();
911        let mid_text: Vec<String> = mid
912            .iter()
913            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
914            .collect();
915        assert_eq!(again_text, mid_text);
916    }
917
918    /// Regression test for the reported "obscenely large response makes the
919    /// whole app grind to a halt" bug: a single multi-megabyte unwrapped
920    /// line used to cost O(line length) on *every single redraw* (both in
921    /// `visible_window`'s per-line `wrap_line` call and in
922    /// `PanelWrap::line_char_len`'s repeated `.chars().count()`), which
923    /// alone took >100ms per frame for a 5MB line. This asserts many
924    /// repeated redraws of such a line stay fast, with a bound generous
925    /// enough not to flake on slow CI hardware while still catching an
926    /// accidental return to O(line length)-per-frame behaviour.
927    #[test]
928    fn visible_window_stays_fast_across_many_redraws_of_a_single_huge_line() {
929        use std::time::{Duration, Instant};
930        let body: String = "x".repeat(5_000_000);
931        let w = wrap(&body, 78);
932
933        let start = Instant::now();
934        for _ in 0..200 {
935            let rows = w.visible_window(0, 30);
936            assert_eq!(
937                rows.len(),
938                30,
939                "the first 30 wrapped rows of a 5,000,000-char line at width 78"
940            );
941        }
942        let elapsed = start.elapsed();
943        assert!(
944            elapsed < Duration::from_secs(2),
945            "200 redraws of a single 5MB line took {elapsed:?} — expected a small fraction of a second"
946        );
947    }
948
949    #[test]
950    fn rebuild_if_needed_skips_rebuilding_on_an_unchanged_pointer_and_width() {
951        let source: Arc<str> = Arc::from("hello\nworld");
952        let mut cache: Option<PanelWrap> = None;
953        PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
954        let first_ptr = cache.as_ref().unwrap().source.as_ptr();
955        // Same Arc, same width -> must not rebuild (same backing pointer).
956        PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
957        assert_eq!(cache.as_ref().unwrap().source.as_ptr(), first_ptr);
958        // Width changed -> must rebuild.
959        PanelWrap::rebuild_if_needed(&mut cache, &source, 20);
960        assert_eq!(cache.as_ref().unwrap().width, 20);
961        // A genuinely new Arc (even with equal content) -> must rebuild too,
962        // since a new response/edit always allocates fresh.
963        let source2: Arc<str> = Arc::from("hello\nworld");
964        PanelWrap::rebuild_if_needed(&mut cache, &source2, 20);
965        assert!(Arc::ptr_eq(&cache.as_ref().unwrap().source, &source2));
966    }
967}
968
969#[cfg(all(test, feature = "ansi"))]
970mod ansi_tests {
971    use super::*;
972    use ratatui::style::Color;
973
974    fn row_text(line: &Line<'static>) -> String {
975        line.spans.iter().map(|s| s.content.as_ref()).collect()
976    }
977
978    const RED_THEN_PLAIN: &str = "\x1b[31mred\x1b[0m plain";
979
980    #[test]
981    fn geometry_and_copy_use_the_stripped_text() {
982        let w = PanelWrap::build_ansi(Arc::from(RED_THEN_PLAIN), 40, WrapMode::Wrap);
983        // Selection/geometry see the plain text, not the escape sequences.
984        assert_eq!(w.line_count(), 1);
985        assert_eq!(w.line_text(0), "red plain");
986        assert_eq!(w.line_char_len(0), 9);
987    }
988
989    #[test]
990    fn rendered_rows_keep_their_colour() {
991        let w = PanelWrap::build_ansi(Arc::from(RED_THEN_PLAIN), 40, WrapMode::Wrap);
992        let rows = w.visible_window(0, 1);
993        assert_eq!(rows.len(), 1);
994        assert_eq!(row_text(&rows[0]), "red plain");
995        // First span is the red "red"; the rest is unstyled " plain".
996        assert_eq!(rows[0].spans[0].content.as_ref(), "red");
997        assert_eq!(rows[0].spans[0].style.fg, Some(Color::Red));
998        let plain: String = rows[0].spans[1..]
999            .iter()
1000            .map(|s| s.content.as_ref())
1001            .collect();
1002        assert_eq!(plain, " plain");
1003        assert_ne!(
1004            rows[0].spans[1].style.fg,
1005            Some(Color::Red),
1006            "the reset run is not red"
1007        );
1008    }
1009
1010    #[test]
1011    fn colour_survives_wrapping_across_a_row_boundary() {
1012        // "red" (3) + " plain" (6) = 9 chars; width 4 wraps to 3 rows.
1013        let w = PanelWrap::build_ansi(Arc::from(RED_THEN_PLAIN), 4, WrapMode::Wrap);
1014        assert_eq!(w.total_rows(), 3);
1015        let rows = w.visible_window(0, 3);
1016        assert_eq!(row_text(&rows[0]), "red ");
1017        // The 'd' at the wrap boundary keeps the red colour.
1018        assert_eq!(rows[0].spans[0].content.as_ref(), "red");
1019        assert_eq!(rows[0].spans[0].style.fg, Some(Color::Red));
1020    }
1021
1022    #[test]
1023    fn clip_mode_keeps_colour_on_the_single_clipped_row() {
1024        let w = PanelWrap::build_ansi(Arc::from(RED_THEN_PLAIN), 4, WrapMode::Clip);
1025        assert_eq!(w.total_rows(), 1);
1026        let rows = w.visible_window(0, 5);
1027        assert_eq!(rows.len(), 1);
1028        assert_eq!(row_text(&rows[0]), "red ", "clipped to width 4");
1029        assert_eq!(rows[0].spans[0].style.fg, Some(Color::Red));
1030    }
1031
1032    #[test]
1033    fn ansi_and_plain_switch_forces_a_rebuild() {
1034        let raw: Arc<str> = Arc::from(RED_THEN_PLAIN);
1035        let mut cache: Option<PanelWrap> = None;
1036        PanelWrap::rebuild_if_needed_ansi(&mut cache, &raw, 40, WrapMode::Wrap);
1037        assert!(cache.as_ref().unwrap().line_styles.is_some());
1038        // Same Arc + width + mode -> no rebuild.
1039        let ptr = cache.as_ref().unwrap().source.as_ptr();
1040        PanelWrap::rebuild_if_needed_ansi(&mut cache, &raw, 40, WrapMode::Wrap);
1041        assert_eq!(cache.as_ref().unwrap().source.as_ptr(), ptr);
1042        // Switching to the plain builder must rebuild (styled -> unstyled).
1043        PanelWrap::rebuild_if_needed_with(&mut cache, &raw, 40, WrapMode::Wrap);
1044        assert!(cache.as_ref().unwrap().line_styles.is_none());
1045    }
1046}