use owo_colors::{OwoColorize, Style};
use std::{
borrow::Cow,
fmt::{self, Write as _},
};
use textwrap::core::display_width;
#[derive(Clone, Debug)]
pub(super) struct Span<'a> {
content: Cow<'a, str>,
style: Style,
}
#[derive(Debug, Default)]
pub(super) struct Line<'a> {
spans: Vec<Span<'a>>,
}
impl<'a> Line<'a> {
pub(super) fn new() -> Self {
Self::default()
}
pub(super) fn push(
&mut self,
content: impl Into<Cow<'a, str>>,
style: Style,
) -> &mut Self {
let content = content.into();
if !content.is_empty() {
self.spans.push(Span { content, style });
}
self
}
pub(super) fn push_plain(
&mut self,
content: impl Into<Cow<'a, str>>,
) -> &mut Self {
self.push(content, Style::default())
}
pub(super) fn write_inline(
&self,
f: &mut fmt::Formatter<'_>,
) -> fmt::Result {
for span in &self.spans {
write!(f, "{}", span.content.as_ref().style(span.style))?;
}
Ok(())
}
}
#[derive(Clone, Copy, Debug)]
pub(super) struct Indent<'a> {
pub(super) string: &'a str,
pub(super) width: usize,
}
impl<'a> Indent<'a> {
pub(super) fn spaces(string: &'a str) -> Self {
debug_assert!(
string.bytes().all(|b| b == b' '),
"Indent::spaces called with non-space content: {string:?}",
);
Self { string, width: string.len() }
}
}
#[derive(Debug, Default)]
struct StyledWord<'a> {
body: Vec<(&'a str, Style)>,
body_width: usize,
trailing_ws_width: usize,
}
impl StyledWord<'_> {
fn is_empty(&self) -> bool {
self.body.is_empty() && self.trailing_ws_width == 0
}
}
fn collect_words<'a>(line: &'a Line<'a>) -> Vec<StyledWord<'a>> {
let mut words = Vec::new();
let mut current = StyledWord::default();
let mut in_trailing_ws = false;
for span in &line.spans {
let mut body_start = 0;
let content = span.content.as_ref();
let bytes = content.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b' ' {
if !in_trailing_ws {
if body_start < i {
let slice = &content[body_start..i];
current.body.push((slice, span.style));
current.body_width += display_width(slice);
}
in_trailing_ws = true;
}
current.trailing_ws_width += 1;
i += 1;
} else {
if in_trailing_ws {
words.push(std::mem::take(&mut current));
in_trailing_ws = false;
body_start = i;
}
i += 1;
}
}
if !in_trailing_ws && body_start < bytes.len() {
let slice = &content[body_start..];
current.body.push((slice, span.style));
current.body_width += display_width(slice);
}
}
if !current.is_empty() {
words.push(current);
}
words
}
pub(super) fn write_wrapped(
f: &mut fmt::Formatter<'_>,
line: &Line<'_>,
width: usize,
indent: Indent<'_>,
) -> fmt::Result {
let words = collect_words(line);
if words.is_empty() {
return Ok(());
}
let content_width = width.saturating_sub(indent.width);
let mut line_ranges: Vec<(usize, usize)> = Vec::new();
let mut start = 0;
let mut current_width = 0;
for (i, word) in words.iter().enumerate() {
let cant_fit_here = current_width > 0
&& current_width + word.body_width > content_width;
if cant_fit_here {
line_ranges.push((start, i));
start = i;
current_width = 0;
}
current_width += word.body_width + word.trailing_ws_width;
}
line_ranges.push((start, words.len()));
for (line_idx, (lo, hi)) in line_ranges.iter().copied().enumerate() {
if line_idx > 0 {
writeln!(f)?;
f.write_str(indent.string)?;
}
let group = &words[lo..hi];
let last_idx = group.len().saturating_sub(1);
for (j, word) in group.iter().enumerate() {
for (slice, style) in &word.body {
write!(f, "{}", slice.style(*style))?;
}
if j < last_idx && word.trailing_ws_width > 0 {
for _ in 0..word.trailing_ws_width {
f.write_char(' ')?;
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn render(line: &Line<'_>, width: usize, indent: &str) -> String {
struct Adapter<'a> {
line: &'a Line<'a>,
width: usize,
indent: Indent<'a>,
}
impl fmt::Display for Adapter<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write_wrapped(f, self.line, self.width, self.indent)
}
}
Adapter { line, width, indent: Indent::spaces(indent) }.to_string()
}
#[test]
fn test_fit_on_one_line() {
let mut line = Line::new();
line.push_plain("GET ").push_plain("/short");
assert_eq!(render(&line, 80, " "), "GET /short");
}
#[test]
fn test_break_at_whitespace() {
let mut line = Line::new();
line.push_plain("alpha beta");
assert_eq!(render(&line, 7, " "), "alpha\n beta");
}
#[test]
fn test_adjacent_spans_form_one_word() {
let mut line = Line::new();
line.push_plain("AB").push_plain("CD");
assert_eq!(render(&line, 3, " "), "ABCD");
}
#[test]
fn test_long_word_overflows() {
let mut line = Line::new();
line.push_plain("a ").push_plain("loooooooooong ").push_plain("z");
assert_eq!(render(&line, 5, ""), "a\nloooooooooong\nz");
}
#[test]
fn test_preserve_styles_across_wrap() {
let bold = Style::new().bold();
let mut line = Line::new();
line.push_plain("aa bb ").push("CC", bold).push_plain("-dd");
let expected = format!("aa bb\n{}-dd", "CC".style(bold));
assert_eq!(render(&line, 7, ""), expected);
}
}