Skip to main content

jev_repl/
wrap.rs

1//! Word wrapping that keeps span styles, so the transcript can be scrolled by exact line count.
2
3use ratatui::style::Style;
4use ratatui::text::{Line, Span};
5
6/// Wrap one styled line to `width` columns. Continuation lines keep the original indentation.
7pub fn wrap(line: &Line<'_>, width: usize) -> Vec<Line<'static>> {
8    if width == 0 {
9        return vec![Line::default()];
10    }
11    let indent = line
12        .spans
13        .first()
14        .map(|s| s.content.len() - s.content.trim_start().len())
15        .unwrap_or(0)
16        .min(width / 2);
17
18    let mut w = Wrapper {
19        width,
20        indent,
21        lines: Vec::new(),
22        cur: Vec::new(),
23        col: 0,
24    };
25    for span in &line.spans {
26        let mut rest: &str = &span.content;
27        while !rest.is_empty() {
28            let end = rest.find(' ').unwrap_or(rest.len());
29            if end == 0 {
30                w.space(span.style);
31                rest = &rest[1..];
32            } else {
33                w.word(&rest[..end], span.style);
34                rest = &rest[end..];
35            }
36        }
37    }
38    w.finish(line.style)
39}
40
41/// Wrap every line and return them in order — what the transcript pane actually draws.
42pub fn wrap_all(lines: &[Line<'static>], width: usize) -> Vec<Line<'static>> {
43    lines.iter().flat_map(|l| wrap(l, width)).collect()
44}
45
46struct Wrapper {
47    width: usize,
48    indent: usize,
49    lines: Vec<Line<'static>>,
50    cur: Vec<Span<'static>>,
51    col: usize,
52}
53
54impl Wrapper {
55    fn word(&mut self, word: &str, style: Style) {
56        let len = word.chars().count();
57        if self.col + len > self.width && self.col > self.indent {
58            self.newline();
59        }
60        if len > self.width {
61            // A single word longer than the pane: hard-break it.
62            let mut chars = word.chars().peekable();
63            while chars.peek().is_some() {
64                let room = self.width.saturating_sub(self.col).max(1);
65                let chunk: String = chars.by_ref().take(room).collect();
66                self.col += chunk.chars().count();
67                self.cur.push(Span::styled(chunk, style));
68                if chars.peek().is_some() {
69                    self.newline();
70                }
71            }
72            return;
73        }
74        self.cur.push(Span::styled(word.to_owned(), style));
75        self.col += len;
76    }
77
78    fn space(&mut self, style: Style) {
79        if self.col == 0 || self.col >= self.width {
80            // Leading indentation is re-applied by `newline`; trailing spaces are dropped.
81            if self.col == 0 && self.cur.is_empty() && self.lines.is_empty() {
82                self.cur.push(Span::styled(" ".to_owned(), style));
83                self.col += 1;
84            }
85            return;
86        }
87        self.cur.push(Span::styled(" ".to_owned(), style));
88        self.col += 1;
89    }
90
91    fn newline(&mut self) {
92        let spans = std::mem::take(&mut self.cur);
93        self.lines.push(Line::from(spans));
94        self.col = 0;
95        if self.indent > 0 {
96            self.cur.push(Span::raw(" ".repeat(self.indent)));
97            self.col = self.indent;
98        }
99    }
100
101    fn finish(mut self, style: Style) -> Vec<Line<'static>> {
102        let spans = std::mem::take(&mut self.cur);
103        if !spans.is_empty() || self.lines.is_empty() {
104            self.lines.push(Line::from(spans));
105        }
106        self.lines
107            .into_iter()
108            .map(|l| l.patch_style(style))
109            .collect()
110    }
111}