Skip to main content

tui_panel_select/
wrap.rs

1//! Character-exact line wrapping primitives.
2//!
3//! These back both a panel's *rendering* (turning one raw line into the
4//! wrapped rows actually shown) and its *selection geometry* (mapping
5//! between logical positions and on-screen rows) — see [`crate::wrapcache`].
6//! Wrapping is character-exact (not word-aware): a line is broken every
7//! `width` display columns, matching how a raw HTTP response body or JSON
8//! preview is displayed.
9
10use ratatui::text::{Line, Span};
11
12/// Wrap a (possibly multi-span, styled) [`Line`] to `width` columns,
13/// breaking exactly on the character boundary and preserving each span's
14/// style across the break. `width == 0` returns the line unchanged.
15pub fn wrap_line(line: Line<'static>, width: usize) -> Vec<Line<'static>> {
16    if width == 0 {
17        return vec![line];
18    }
19    let mut out: Vec<Line<'static>> = Vec::new();
20    let mut cur: Vec<Span<'static>> = Vec::new();
21    let mut cur_w = 0usize;
22    for span in line.spans {
23        let mut remaining: &str = span.content.as_ref();
24        loop {
25            if remaining.is_empty() {
26                break;
27            }
28            let avail = width - cur_w;
29            if avail == 0 {
30                out.push(Line::from(std::mem::take(&mut cur)));
31                cur_w = 0;
32                continue;
33            }
34            let take: String = remaining.chars().take(avail).collect();
35            cur_w += take.chars().count();
36            remaining = &remaining[take.len()..];
37            cur.push(Span::styled(take, span.style));
38        }
39    }
40    out.push(Line::from(cur));
41    out
42}
43
44/// Wrap only a bounded window of a single (unstyled) line — skip
45/// `skip_rows` whole wrapped rows, then wrap at most `max_rows` more,
46/// without ever touching or allocating the rest of the line. Unlike
47/// [`wrap_line`], whose cost is proportional to the *entire* line, this
48/// keeps cost proportional to `(skip_rows + max_rows) * width` — critical
49/// for panels holding one enormous raw line (e.g. a large base64 blob or
50/// minified JSON body pasted with no newlines), where re-wrapping the
51/// whole line on every redraw would grind the app to a halt regardless of
52/// how few rows are actually on screen.
53pub fn wrap_line_window(
54    text: &str,
55    width: usize,
56    skip_rows: usize,
57    max_rows: usize,
58) -> Vec<Line<'static>> {
59    if max_rows == 0 {
60        return Vec::new();
61    }
62    if width == 0 {
63        return if skip_rows == 0 {
64            vec![Line::raw(text.to_string())]
65        } else {
66            Vec::new()
67        };
68    }
69    let skip_chars = skip_rows.saturating_mul(width);
70    let take_chars = max_rows.saturating_mul(width);
71    let windowed: String = text.chars().skip(skip_chars).take(take_chars).collect();
72    if windowed.is_empty() {
73        return Vec::new();
74    }
75    wrap_line(Line::raw(windowed), width)
76}
77
78/// Number of wrapped rows a line of `char_len` characters produces at
79/// `width` display columns, matching [`wrap_line`]'s own boundary math
80/// exactly (one row minimum, even for an empty line). Used to size the
81/// total scrollable extent and locate a scroll position without ever
82/// wrapping every line.
83pub fn wrapped_row_count(char_len: usize, width: usize) -> usize {
84    if width == 0 || char_len == 0 {
85        1
86    } else {
87        char_len.div_ceil(width)
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    /// `wrapped_row_count` must match `wrap_line`'s own boundary math
96    /// exactly, since the wrap cache relies on it to size the total
97    /// scrollable extent and locate a scroll position without ever wrapping
98    /// every line.
99    #[test]
100    fn wrapped_row_count_matches_wrap_line_for_various_lengths() {
101        for (len, width) in [(0, 10), (1, 10), (10, 10), (11, 10), (25, 10), (7, 0)] {
102            let text = "x".repeat(len);
103            let actual = wrap_line(Line::raw(text), width).len();
104            assert_eq!(
105                wrapped_row_count(len, width),
106                actual,
107                "len={len} width={width}"
108            );
109        }
110    }
111}