use ratatui::buffer::CellWidth;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use std::ops::Range;
#[derive(Debug, Clone, PartialEq)]
pub(super) struct Piece {
pub text: String,
pub style: Style,
pub link: Option<usize>,
}
impl Piece {
pub fn new(text: impl Into<String>, style: Style) -> Piece {
Piece {
text: text.into(),
style,
link: None,
}
}
}
#[derive(Debug, Clone)]
pub(super) struct Row {
pub line: Line<'static>,
pub links: Vec<(Range<u16>, usize)>,
}
#[derive(Clone, Copy)]
struct Cell {
ch: char,
width: u16,
style: Style,
link: Option<usize>,
}
fn char_width(c: char) -> u16 {
c.encode_utf8(&mut [0; 4]).cell_width()
}
fn spans_width(spans: &[Span<'static>]) -> usize {
spans.iter().map(Span::width).sum()
}
pub(super) fn wrap(
pieces: &[Piece],
width: usize,
first: &[Span<'static>],
rest: &[Span<'static>],
) -> Vec<Row> {
let cells: Vec<Cell> = pieces
.iter()
.flat_map(|p| {
p.text.chars().filter(|c| !c.is_control()).map(|ch| Cell {
ch,
width: char_width(ch),
style: p.style,
link: p.link,
})
})
.collect();
let mut out = Vec::new();
let mut left: &[Cell] = &cells;
let mut prefix = first;
loop {
let indent = spans_width(prefix);
let room = width.saturating_sub(indent).max(1);
let mut used = 0usize;
let mut fit = 0;
while fit < left.len() && used + left[fit].width as usize <= room {
used += left[fit].width as usize;
fit += 1;
}
let cut = match fit == left.len() {
true => fit,
false => match (1..=fit).rev().find(|&i| left[i].ch == ' ') {
Some(i) => i,
None => fit.max(1),
},
};
out.push(row(prefix, &left[..cut], indent));
left = &left[cut..];
while left.first().is_some_and(|c| c.ch == ' ') {
left = &left[1..];
}
if left.is_empty() {
return out;
}
prefix = rest;
}
}
pub(super) fn wrap_line(line: &Line<'static>, width: usize, indent: &str) -> Vec<Line<'static>> {
let pieces: Vec<Piece> = line
.spans
.iter()
.map(|s| Piece::new(s.content.to_string(), line.style.patch(s.style)))
.collect();
let prefix = [Span::raw(indent.to_string())];
wrap(&pieces, width, &prefix, &prefix)
.into_iter()
.map(|r| r.line)
.collect()
}
fn row(prefix: &[Span<'static>], cells: &[Cell], indent: usize) -> Row {
let mut spans: Vec<Span<'static>> = prefix.to_vec();
let mut links = Vec::new();
let mut col = indent as u16;
let mut i = 0;
while i < cells.len() {
let (style, link) = (cells[i].style, cells[i].link);
let mut text = String::new();
let start = col;
while i < cells.len() && cells[i].style == style && cells[i].link == link {
text.push(cells[i].ch);
col += cells[i].width;
i += 1;
}
if let Some(link) = link {
links.push((start..col, link));
}
spans.push(Span::styled(text, style));
}
Row {
line: Line::from(spans),
links,
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::{Color, Modifier};
fn text(row: &Row) -> String {
row.line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn breaks_at_the_last_space_and_keeps_each_style() {
let red = Style::default().fg(Color::Red);
let pieces = [
Piece::new("error: ", red),
Piece::new("expected one argument", Style::default()),
];
let rows = wrap(&pieces, 16, &[], &[]);
let texts: Vec<String> = rows.iter().map(text).collect();
assert_eq!(texts, ["error: expected", "one argument"]);
assert_eq!(rows[0].line.spans[0].style, red);
assert_eq!(rows[0].line.spans[1].style, Style::default());
}
#[test]
fn a_long_token_is_hard_cut() {
let rows = wrap(&[Piece::new("abcdefghij", Style::default())], 4, &[], &[]);
let texts: Vec<String> = rows.iter().map(text).collect();
assert_eq!(texts, ["abcd", "efgh", "ij"]);
}
#[test]
fn a_link_reports_its_columns_on_every_row() {
let link = Piece {
text: "the whole guide".into(),
style: Style::default().add_modifier(Modifier::UNDERLINED),
link: Some(0),
};
let first = [Span::raw("• ")];
let rest = [Span::raw(" ")];
let rows = wrap(
&[Piece::new("see ", Style::default()), link],
14,
&first,
&rest,
);
assert_eq!(text(&rows[0]), "• see the");
assert_eq!(rows[0].links, vec![(6..9, 0)]);
assert_eq!(text(&rows[1]), " whole guide");
assert_eq!(rows[1].links, vec![(2..13, 0)]);
}
#[test]
fn wide_glyphs_count_twice() {
let rows = wrap(&[Piece::new("日本語です", Style::default())], 6, &[], &[]);
let texts: Vec<String> = rows.iter().map(text).collect();
assert_eq!(texts, ["日本語", "です"]);
}
}