use ratatui::text::{Line, Span};
#[derive(Debug, Clone)]
pub(in crate::tui::view) struct Row<'a> {
pub(in crate::tui::view) line: Line<'a>,
pub(in crate::tui::view) indent: usize,
}
impl<'a> Row<'a> {
pub(in crate::tui::view) fn hang(line: impl Into<Line<'a>>, indent: usize) -> Self {
Self {
line: line.into(),
indent,
}
}
pub(in crate::tui::view) fn flow(line: impl Into<Line<'a>>) -> Self {
Self::hang(line, 0)
}
}
pub(super) fn cols(text: &str) -> usize {
Span::raw(text).width()
}
pub(super) fn natural(rows: &[Row]) -> usize {
rows.iter().map(|row| row.line.width()).max().unwrap_or(0)
}
fn pieces(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut in_space = false;
for (at, ch) in text.char_indices() {
if ch == ' ' {
in_space = true;
} else if in_space {
out.push(&text[start..at]);
start = at;
in_space = false;
}
}
if start < text.len() {
out.push(&text[start..]);
}
out
}
fn split_at(word: &str, room: usize) -> usize {
let fits = word
.char_indices()
.scan(0, |taken, (at, ch)| {
*taken += cols(&ch.to_string());
Some((at + ch.len_utf8(), *taken))
})
.take_while(|(_, taken)| *taken <= room)
.last()
.map_or(0, |(end, _)| end);
let hyphen = word[..fits].rfind('-').map(|at| at + 1);
match hyphen.unwrap_or(fits) {
0 => word.chars().next().map_or(0, char::len_utf8),
cut => cut,
}
}
struct Filling<'a> {
lines: Vec<Line<'a>>,
spans: Vec<Span<'a>>,
used: usize,
}
impl<'a> Filling<'a> {
fn push(&mut self, text: String, style: ratatui::style::Style) {
self.used += cols(&text);
self.spans.push(Span::styled(text, style));
}
fn next(&mut self, indent: usize) {
if let Some(last) = self.spans.last_mut() {
let trimmed = last.content.trim_end().to_string();
last.content = trimmed.into();
}
self.lines.push(Line::from(std::mem::take(&mut self.spans)));
self.spans.push(Span::raw(" ".repeat(indent)));
self.used = indent;
}
}
pub(super) fn wrap<'a>(row: &Row<'a>, width: usize) -> Vec<Line<'a>> {
let width = width.max(1);
let indent = if row.indent + 8 <= width {
row.indent
} else {
0
};
if row.line.width() <= width {
return vec![row.line.clone()];
}
let mut fill = Filling {
lines: Vec::new(),
spans: Vec::new(),
used: 0,
};
let mut fresh = true;
for span in &row.line.spans {
for piece in pieces(&span.content) {
let word = cols(piece.trim_end());
let long = indent + word > width;
let sliver = width.saturating_sub(fill.used) < 8;
if !fresh && fill.used + word > width && (!long || sliver) {
fill.next(indent);
fresh = true;
}
if fresh && word == 0 && !fill.lines.is_empty() {
continue;
}
let mut rest = piece;
while fill.used + cols(rest.trim_end()) > width {
let cut = split_at(rest, width.saturating_sub(fill.used));
fill.push(rest[..cut].to_string(), span.style);
rest = &rest[cut..];
fill.next(indent);
}
if !rest.is_empty() {
fill.push(rest.to_string(), span.style);
fresh = false;
}
}
}
if fill
.spans
.iter()
.any(|span| !span.content.trim().is_empty())
{
fill.next(0);
}
fill.lines
}
pub(super) fn wrap_all<'a>(rows: &[Row<'a>], width: usize) -> Vec<Line<'a>> {
rows.iter().flat_map(|row| wrap(row, width)).collect()
}
pub(super) fn height(rows: &[Row], width: usize) -> usize {
wrap_all(rows, width).len()
}
const PANEL_MIN: u16 = 24;
pub(super) fn split(width: u16, left: &[Row], right: &[Row]) -> (u16, u16) {
let fit = |rows: &[Row], outer: u16| height(rows, usize::from(outer.saturating_sub(2)));
let natural_right = u16::try_from(natural(right) + 2).unwrap_or(u16::MAX);
let natural_left = u16::try_from(natural(left) + 2).unwrap_or(u16::MAX);
let tall = |right_width: u16| {
let lines = fit(left, width - right_width).max(fit(right, right_width));
u16::try_from(lines + 2).unwrap_or(u16::MAX)
};
if natural_left.saturating_add(natural_right) <= width {
return (natural_right, tall(natural_right));
}
let widest = natural_right.min(width.saturating_sub(PANEL_MIN));
let mut best = (widest, tall(widest));
for right_width in (PANEL_MIN..widest).rev() {
let height = tall(right_width);
if height < best.1 {
best = (right_width, height);
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
fn text(lines: &[Line]) -> Vec<String> {
lines.iter().map(|line| line.to_string()).collect()
}
#[test]
fn words_move_whole_and_continuations_hang() {
let row = Row::hang(
"Heartbeat last heard 0 s ago, no heartbeat answered yet",
11,
);
assert_eq!(
text(&wrap(&row, 40)),
[
"Heartbeat last heard 0 s ago, no",
" heartbeat answered yet",
]
);
}
#[test]
fn a_uuid_breaks_between_its_groups() {
let row = Row::hang("Device UUID 784f3147-2ed1-476d-8a0c-0123456789ab", 12);
assert_eq!(
text(&wrap(&row, 35)),
[
"Device UUID 784f3147-2ed1-476d-",
" 8a0c-0123456789ab",
]
);
}
#[test]
fn a_word_wider_than_the_line_is_split() {
let row = Row::flow("abcdefghij klm");
assert_eq!(text(&wrap(&row, 4)), ["abcd", "efgh", "ij", "klm"]);
}
#[test]
fn a_short_row_is_left_alone() {
let row = Row::flow("State connected");
assert_eq!(text(&wrap(&row, 80)), ["State connected"]);
}
#[test]
fn panels_take_their_natural_widths_when_they_fit() {
let left = [Row::flow("x".repeat(40))];
let right = [Row::flow("y".repeat(30))];
assert_eq!(split(120, &left, &right), (32, 3));
}
#[test]
fn a_narrow_split_keeps_the_fewest_lines() {
let left = [Row::flow("aaaa bbbb cccc dddd eeee ffff")];
let right = [Row::flow("gggg hhhh iiii jjjj")];
let (right_width, height) = split(50, &left, &right);
assert_eq!(height, 4, "{right_width}");
}
}