use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::docs::DocId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ModalLinkTarget {
pub(crate) id: DocId,
pub(crate) fragment: Option<&'static str>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ModalLink {
pub(crate) line_idx: usize,
pub(crate) span_idx: usize,
pub(crate) target: ModalLinkTarget,
pub(crate) label: String,
}
impl ModalLink {
pub(crate) fn new(
line_idx: usize,
span_idx: usize,
target: ModalLinkTarget,
label: impl Into<String>,
) -> Self {
Self {
line_idx,
span_idx,
target,
label: label.into(),
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct WrappedRow {
pub(crate) line: Line<'static>,
pub(crate) origins: Vec<(usize, usize)>,
}
struct Piece {
symbol: String,
style: Style,
origin: (usize, usize),
width: u16,
}
impl Piece {
fn is_whitespace(&self) -> bool {
self.symbol.chars().all(char::is_whitespace)
}
}
pub(crate) fn wrap_rows(body: &[Line<'_>], width: u16) -> Vec<WrappedRow> {
if width == 0 {
return body
.iter()
.map(|line| WrappedRow {
line: owned_line(line),
origins: Vec::new(),
})
.collect();
}
let mut out = Vec::new();
for (line_idx, line) in body.iter().enumerate() {
wrap_one_line(line, line_idx, width, &mut out);
}
out
}
fn wrap_one_line(line: &Line<'_>, line_idx: usize, width: u16, out: &mut Vec<WrappedRow>) {
let mut pending_row: Vec<Piece> = Vec::new();
let mut pending_word: Vec<Piece> = Vec::new();
let mut pending_ws: std::collections::VecDeque<Piece> = std::collections::VecDeque::new();
let mut row_width: u16 = 0;
let mut word_width: u16 = 0;
let mut ws_width: u16 = 0;
let mut non_ws_previous = false;
let start_len = out.len();
for (span_idx, span) in line.spans.iter().enumerate() {
for g in span.content.as_ref().graphemes(true) {
let piece = Piece {
symbol: g.to_owned(),
style: line.style.patch(span.style),
origin: (line_idx, span_idx),
width: UnicodeWidthStr::width(g) as u16,
};
if piece.width > width {
continue;
}
let is_ws = piece.is_whitespace();
let word_found = non_ws_previous && is_ws;
let untrimmed_overflow =
pending_row.is_empty() && word_width + ws_width + piece.width > width;
if word_found || untrimmed_overflow {
pending_row.extend(pending_ws.drain(..));
row_width += ws_width;
pending_row.append(&mut pending_word);
row_width += word_width;
ws_width = 0;
word_width = 0;
}
let row_full = row_width >= width;
let word_overflow = piece.width > 0 && row_width + ws_width + word_width >= width;
if row_full || word_overflow {
let mut remaining = width.saturating_sub(row_width);
out.push(row_from(std::mem::take(&mut pending_row)));
row_width = 0;
while let Some(front) = pending_ws.front() {
if front.width > remaining {
break;
}
ws_width -= front.width;
remaining -= front.width;
pending_ws.pop_front();
}
if is_ws && pending_ws.is_empty() {
continue;
}
}
if is_ws {
ws_width += piece.width;
pending_ws.push_back(piece);
} else {
word_width += piece.width;
pending_word.push(piece);
}
non_ws_previous = !is_ws;
}
}
pending_row.extend(pending_ws.drain(..));
pending_row.append(&mut pending_word);
if !pending_row.is_empty() {
out.push(row_from(pending_row));
}
if out.len() == start_len {
out.push(WrappedRow::default());
}
if let Some(alignment) = line.alignment {
for row in &mut out[start_len..] {
row.line = std::mem::take(&mut row.line).alignment(alignment);
}
}
}
fn row_from(pieces: Vec<Piece>) -> WrappedRow {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut origins: Vec<(usize, usize)> = Vec::new();
let mut current: Option<(Style, (usize, usize), String)> = None;
for p in pieces {
for _ in 0..p.width {
origins.push(p.origin);
}
match &mut current {
Some((style, origin, text)) if *style == p.style && *origin == p.origin => {
text.push_str(&p.symbol);
}
_ => {
if let Some((style, _, text)) = current.take() {
spans.push(Span::styled(text, style));
}
current = Some((p.style, p.origin, p.symbol));
}
}
}
if let Some((style, _, text)) = current {
spans.push(Span::styled(text, style));
}
WrappedRow {
line: Line::from(spans),
origins,
}
}
fn owned_line(line: &Line<'_>) -> Line<'static> {
let mut out = Line::from(
line.spans
.iter()
.map(|s| Span::styled(s.content.as_ref().to_owned(), s.style))
.collect::<Vec<_>>(),
)
.style(line.style);
out.alignment = line.alignment;
out
}
fn row_start_col(row: &WrappedRow, width: usize) -> usize {
use ratatui::layout::Alignment;
let row_width = row.origins.len();
let slack = width.saturating_sub(row_width);
match row.line.alignment {
Some(Alignment::Center) => slack / 2,
Some(Alignment::Right) => slack,
Some(Alignment::Left) | None => 0,
}
}
pub(crate) fn link_rects(
rows: &[WrappedRow],
links: &[ModalLink],
area: Rect,
scroll: u16,
) -> Vec<(usize, Rect)> {
let mut out = Vec::new();
if area.height == 0 || area.width == 0 {
return out;
}
let first = scroll as usize;
let last = first.saturating_add(area.height as usize).min(rows.len());
for (row_idx, row) in rows.iter().enumerate().take(last).skip(first) {
let y = area.y + (row_idx - first) as u16;
let offset = row_start_col(row, area.width as usize);
for (link_idx, link) in links.iter().enumerate() {
let key = (link.line_idx, link.span_idx);
let Some(start) = row.origins.iter().position(|o| *o == key) else {
continue;
};
let end = row.origins.iter().rposition(|o| *o == key).unwrap_or(start);
let start = start + offset;
let end = end + offset;
if start >= area.width as usize {
continue;
}
let width = (end + 1 - start).min(area.width as usize - start);
out.push((
link_idx,
Rect {
x: area.x + start as u16,
y,
width: width as u16,
height: 1,
},
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::widgets::{Paragraph, Widget, Wrap};
use ratatui::Terminal;
fn body() -> Vec<Line<'static>> {
vec![
Line::raw("The quick brown fox jumps over the lazy dog and keeps running onward"),
Line::raw(""),
Line::raw(" indented continuation text that is long enough to wrap at least once"),
Line::raw("supercalifragilisticexpialidociousandthensomemoretoforceahardsplit"),
Line::from(vec![
Span::raw("See "),
Span::raw("Terminal compatibility"),
Span::raw(" for the list of terminals."),
]),
Line::raw("a centred paragraph that has to wrap somewhere")
.alignment(ratatui::layout::Alignment::Center),
]
}
fn both_renderings(
lines: &[Line<'static>],
width: u16,
height: u16,
) -> (Vec<String>, Vec<String>) {
let cells = |f: &dyn Fn(Rect, &mut ratatui::buffer::Buffer)| -> Vec<String> {
let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
term.draw(|frame| {
let area = Rect::new(0, 0, width, height);
f(area, frame.buffer_mut());
})
.unwrap();
let buf = term.backend().buffer().clone();
(0..height)
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol().to_owned())
.collect::<String>()
})
.collect()
};
let native = cells(&|area, buf| {
Paragraph::new(lines.to_vec())
.wrap(Wrap { trim: false })
.render(area, buf);
});
let ours = cells(&|area, buf| {
let rows = wrap_rows(lines, area.width);
let painted: Vec<Line<'static>> = rows.into_iter().map(|r| r.line).collect();
Paragraph::new(painted).render(area, buf);
});
(native, ours)
}
#[test]
fn wrap_matches_ratatui_paragraph_wrapping() {
for width in [12u16, 17, 20, 31, 40, 79] {
let (native, ours) = both_renderings(&body(), width, 30);
assert_eq!(native, ours, "wrap diverged at width {width}");
}
}
#[test]
fn a_line_level_style_survives_the_pre_wrap() {
use ratatui::style::{Color, Modifier};
let base = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
let lines = vec![Line::from(vec![
Span::raw("plain "),
Span::styled("green", Style::default().fg(Color::Green)),
])
.style(base)];
let rows = wrap_rows(&lines, 40);
let spans = &rows[0].line.spans;
assert_eq!(spans[0].style.fg, Some(Color::Red), "the line's own color");
assert!(spans[0].style.add_modifier.contains(Modifier::BOLD));
assert_eq!(spans[1].style.fg, Some(Color::Green));
assert!(
spans[1].style.add_modifier.contains(Modifier::BOLD),
"the line's modifier reaches a span that sets only a color"
);
}
#[test]
fn a_line_alignment_survives_the_pre_wrap() {
use ratatui::layout::Alignment;
let lines = vec![
Line::raw("alpha bravo charlie delta echo").alignment(Alignment::Center),
Line::raw("left"),
];
let rows = wrap_rows(&lines, 12);
assert!(rows.len() > 2, "the first line wrapped");
for row in &rows[..rows.len() - 1] {
assert_eq!(
row.line.alignment,
Some(Alignment::Center),
"every row a centred line wrapped into stays centred"
);
}
assert_eq!(rows.last().expect("a row").line.alignment, None);
}
#[test]
fn a_blank_body_line_still_occupies_one_row() {
let rows = wrap_rows(&[Line::raw("a"), Line::raw(""), Line::raw("b")], 10);
assert_eq!(rows.len(), 3);
assert_eq!(rows[1].line.width(), 0);
}
#[test]
fn origins_name_the_span_behind_every_column() {
let lines = vec![Line::from(vec![Span::raw("ab"), Span::raw("cd")])];
let rows = wrap_rows(&lines, 10);
assert_eq!(
rows[0].origins,
vec![(0, 0), (0, 0), (0, 1), (0, 1)],
"each column reports the span it came from"
);
}
#[test]
fn a_double_width_grapheme_claims_two_columns() {
let lines = vec![Line::from(vec![Span::raw("東"), Span::raw("x")])];
let rows = wrap_rows(&lines, 10);
assert_eq!(rows[0].origins, vec![(0, 0), (0, 0), (0, 1)]);
}
#[test]
fn a_link_rect_covers_exactly_its_span() {
let lines = vec![Line::from(vec![
Span::raw("See "),
Span::raw("the docs"),
Span::raw(" now"),
])];
let rows = wrap_rows(&lines, 40);
let links = vec![ModalLink::new(
0,
1,
ModalLinkTarget {
id: DocId::Index,
fragment: None,
},
"the docs",
)];
let rects = link_rects(&rows, &links, Rect::new(3, 5, 40, 4), 0);
assert_eq!(rects.len(), 1);
let (idx, r) = rects[0];
assert_eq!(idx, 0);
assert_eq!((r.x, r.y, r.width, r.height), (3 + 4, 5, 8, 1));
}
#[test]
fn a_wrapped_link_reports_one_rect_per_row() {
let lines = vec![Line::from(vec![
Span::raw("x "),
Span::raw("alpha beta gamma"),
])];
let rows = wrap_rows(&lines, 12);
let links = vec![ModalLink::new(
0,
1,
ModalLinkTarget {
id: DocId::Index,
fragment: None,
},
"alpha beta gamma",
)];
let rects = link_rects(&rows, &links, Rect::new(0, 0, 12, 6), 0);
assert!(
rects.len() >= 2,
"a link wrapping across rows hit-tests on each: {rects:?}"
);
assert!(rects.iter().all(|(i, _)| *i == 0));
}
#[test]
fn a_scrolled_away_link_yields_no_rect() {
let lines = vec![
Line::raw("one"),
Line::raw("two"),
Line::from(vec![Span::raw("three")]),
];
let rows = wrap_rows(&lines, 20);
let links = vec![ModalLink::new(
2,
0,
ModalLinkTarget {
id: DocId::Index,
fragment: None,
},
"three",
)];
let rects = link_rects(&rows, &links, Rect::new(0, 0, 20, 1), 0);
assert!(rects.is_empty());
let rects = link_rects(&rows, &links, Rect::new(0, 0, 20, 1), 2);
assert_eq!(rects.len(), 1);
assert_eq!(rects[0].1.y, 0, "the row paints at the top of the window");
}
fn painted_link_text(lines: &[Line<'static>], links: &[ModalLink], width: u16) -> Vec<String> {
let height = 6u16;
let area = Rect::new(0, 0, width, height);
let rows = wrap_rows(lines, width);
let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
term.draw(|frame| {
let painted: Vec<Line<'static>> = rows.iter().map(|r| r.line.clone()).collect();
Paragraph::new(painted).render(area, frame.buffer_mut());
})
.unwrap();
let buf = term.backend().buffer().clone();
link_rects(&rows, links, area, 0)
.into_iter()
.map(|(_, r)| {
(r.x..r.x + r.width)
.map(|x| buf[(x, r.y)].symbol().to_owned())
.collect::<String>()
})
.collect()
}
#[test]
fn a_centred_link_hit_tests_where_it_is_painted() {
let lines = vec![Line::from(vec![
Span::raw("See "),
Span::raw("the docs"),
Span::raw(" now"),
])
.alignment(ratatui::layout::Alignment::Center)];
let links = vec![ModalLink::new(
0,
1,
ModalLinkTarget {
id: DocId::Index,
fragment: None,
},
"the docs",
)];
assert_eq!(painted_link_text(&lines, &links, 40), vec!["the docs"]);
}
#[test]
fn a_right_aligned_link_hit_tests_where_it_is_painted() {
let lines = vec![Line::from(vec![Span::raw("See "), Span::raw("the docs")])
.alignment(ratatui::layout::Alignment::Right)];
let links = vec![ModalLink::new(
0,
1,
ModalLinkTarget {
id: DocId::Index,
fragment: None,
},
"the docs",
)];
assert_eq!(painted_link_text(&lines, &links, 40), vec!["the docs"]);
}
#[test]
fn an_unaligned_link_is_unmoved_by_the_alignment_offset() {
let lines = vec![Line::from(vec![
Span::raw("See "),
Span::raw("the docs"),
Span::raw(" now"),
])];
let links = vec![ModalLink::new(
0,
1,
ModalLinkTarget {
id: DocId::Index,
fragment: None,
},
"the docs",
)];
assert_eq!(painted_link_text(&lines, &links, 40), vec!["the docs"]);
}
}