use ratatui::text::Line;
use ratatui::text::Span;
use std::borrow::Cow;
use std::ops::Range;
use textwrap::core::display_width;
use textwrap::core::Word;
use textwrap::Options;
use textwrap::WordSeparator;
fn push_owned_lines(src: &[Line<'_>], out: &mut Vec<Line<'static>>) {
out.extend(src.iter().map(|line| {
Line {
style: line.style,
alignment: line.alignment,
spans: line
.spans
.iter()
.map(|span| Span::styled(span.content.to_string(), span.style))
.collect(),
}
}));
}
pub fn wrap_ranges<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
where
O: Into<Options<'a>>,
{
let opts = width_or_options.into();
let mut lines: Vec<Range<usize>> = Vec::new();
let mut cursor = 0usize;
for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() {
match line {
std::borrow::Cow::Borrowed(slice) => {
let range = borrowed_slice_range(text, slice).unwrap_or_else(|| {
let synthetic_prefix = if line_index == 0 {
opts.initial_indent
} else {
opts.subsequent_indent
};
map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix)
});
let start = range.start;
let end = range.end;
let trailing_spaces = text[end..].chars().take_while(|c| *c == ' ').count();
lines.push(start..end + trailing_spaces + 1);
cursor = end + trailing_spaces;
}
std::borrow::Cow::Owned(slice) => {
let synthetic_prefix = if line_index == 0 {
opts.initial_indent
} else {
opts.subsequent_indent
};
let mapped = map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix);
let trailing_spaces = text[mapped.end..].chars().take_while(|c| *c == ' ').count();
lines.push(mapped.start..mapped.end + trailing_spaces + 1);
cursor = mapped.end + trailing_spaces;
}
}
}
lines
}
pub fn wrap_ranges_trim<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
where
O: Into<Options<'a>>,
{
let opts = width_or_options.into();
let mut lines: Vec<Range<usize>> = Vec::new();
let mut cursor = 0usize;
for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() {
match line {
std::borrow::Cow::Borrowed(slice) => {
let range = borrowed_slice_range(text, slice).unwrap_or_else(|| {
let synthetic_prefix = if line_index == 0 {
opts.initial_indent
} else {
opts.subsequent_indent
};
map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix)
});
cursor = range.end;
lines.push(range);
}
std::borrow::Cow::Owned(slice) => {
let synthetic_prefix = if line_index == 0 {
opts.initial_indent
} else {
opts.subsequent_indent
};
let mapped = map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix);
lines.push(mapped.clone());
cursor = mapped.end;
}
}
}
lines
}
fn borrowed_slice_range(text: &str, slice: &str) -> Option<Range<usize>> {
let text_start = text.as_ptr() as usize;
let text_end = text_start.checked_add(text.len())?;
let slice_start = slice.as_ptr() as usize;
let slice_end = slice_start.checked_add(slice.len())?;
if slice_start < text_start || slice_end > text_end {
return None;
}
Some((slice_start - text_start)..(slice_end - text_start))
}
fn map_owned_wrapped_line_to_range(
text: &str,
cursor: usize,
wrapped: &str,
synthetic_prefix: &str,
) -> Range<usize> {
let wrapped = if synthetic_prefix.is_empty() {
wrapped
} else {
wrapped.strip_prefix(synthetic_prefix).unwrap_or(wrapped)
};
let mut start = cursor;
while start < text.len() && !wrapped.starts_with(' ') {
let Some(ch) = text[start..].chars().next() else {
break;
};
if ch != ' ' {
break;
}
start += ch.len_utf8();
}
let mut end = start;
let mut saw_source_char = false;
let mut chars = wrapped.chars().peekable();
while let Some(ch) = chars.next() {
if end < text.len() {
let Some(src) = text[end..].chars().next() else {
unreachable!("checked end < text.len()");
};
if ch == src {
end += src.len_utf8();
saw_source_char = true;
continue;
}
}
if ch == '-' && chars.peek().is_none() {
continue;
}
if !saw_source_char {
continue;
}
tracing::warn!(
wrapped = %wrapped,
cursor,
end,
"wrap_ranges: could not fully map owned line; returning partial source range"
);
break;
}
start..end
}
pub fn line_contains_url_like(line: &Line<'_>) -> bool {
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
text_contains_url_like(&text)
}
pub fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool {
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
text_has_mixed_url_and_non_url_tokens(&text)
}
pub fn text_contains_url_like(text: &str) -> bool {
text.split_ascii_whitespace().any(is_url_like_token)
}
fn text_has_mixed_url_and_non_url_tokens(text: &str) -> bool {
let mut saw_url = false;
let mut saw_non_url = false;
for raw_token in text.split_ascii_whitespace() {
if is_url_like_token(raw_token) {
saw_url = true;
} else if is_substantive_non_url_token(raw_token) {
saw_non_url = true;
}
if saw_url && saw_non_url {
return true;
}
}
false
}
fn is_url_like_token(raw_token: &str) -> bool {
let token = trim_url_token(raw_token);
!token.is_empty() && (is_absolute_url_like(token) || is_bare_url_like(token))
}
fn is_substantive_non_url_token(raw_token: &str) -> bool {
let token = trim_url_token(raw_token);
if token.is_empty() || is_decorative_marker_token(raw_token, token) {
return false;
}
token.chars().any(char::is_alphanumeric)
}
fn is_decorative_marker_token(raw_token: &str, token: &str) -> bool {
let raw = raw_token.trim();
matches!(
raw,
"-" | "*"
| "+"
| "•"
| "◦"
| "▪"
| ">"
| "|"
| "│"
| "┆"
| "└"
| "├"
| "┌"
| "┐"
| "┘"
| "┼"
) || is_ordered_list_marker(raw, token)
}
fn is_ordered_list_marker(raw_token: &str, token: &str) -> bool {
token.chars().all(|c| c.is_ascii_digit())
&& (raw_token.ends_with('.') || raw_token.ends_with(')'))
}
fn trim_url_token(token: &str) -> &str {
token.trim_matches(|c: char| {
matches!(
c,
'(' | ')'
| '['
| ']'
| '{'
| '}'
| '<'
| '>'
| ','
| '.'
| ';'
| ':'
| '!'
| '\''
| '"'
)
})
}
fn is_absolute_url_like(token: &str) -> bool {
if !token.contains("://") {
return false;
}
if let Ok(url) = url::Url::parse(token) {
let scheme = url.scheme().to_ascii_lowercase();
if matches!(
scheme.as_str(),
"http" | "https" | "ftp" | "ftps" | "ws" | "wss"
) {
return url.host_str().is_some();
}
return true;
}
has_valid_scheme_prefix(token)
}
fn has_valid_scheme_prefix(token: &str) -> bool {
let Some((scheme, rest)) = token.split_once("://") else {
return false;
};
if scheme.is_empty() || rest.is_empty() {
return false;
}
let mut chars = scheme.chars();
let Some(first) = chars.next() else {
return false;
};
first.is_ascii_alphabetic()
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
}
fn is_bare_url_like(token: &str) -> bool {
let (host_port, has_trailer) = split_host_port_and_trailer(token);
if host_port.is_empty() {
return false;
}
if !has_trailer && !host_port.to_ascii_lowercase().starts_with("www.") {
return false;
}
let (host, port) = split_host_and_port(host_port);
if host.is_empty() {
return false;
}
if let Some(port) = port {
if !is_valid_port(port) {
return false;
}
}
host.eq_ignore_ascii_case("localhost") || is_ipv4(host) || is_domain_name(host)
}
fn split_host_port_and_trailer(token: &str) -> (&str, bool) {
if let Some(idx) = token.find(['/', '?', '#']) {
(&token[..idx], true)
} else {
(token, false)
}
}
fn split_host_and_port(host_port: &str) -> (&str, Option<&str>) {
if host_port.starts_with('[') {
return (host_port, None);
}
if let Some((host, port)) = host_port.rsplit_once(':') {
if !host.is_empty() && !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
return (host, Some(port));
}
}
(host_port, None)
}
fn is_valid_port(port: &str) -> bool {
if port.is_empty() || port.len() > 5 || !port.chars().all(|c| c.is_ascii_digit()) {
return false;
}
port.parse::<u16>().is_ok()
}
fn is_ipv4(host: &str) -> bool {
let parts: Vec<&str> = host.split('.').collect();
if parts.len() != 4 {
return false;
}
parts
.iter()
.all(|part| !part.is_empty() && part.parse::<u8>().is_ok())
}
fn is_domain_name(host: &str) -> bool {
let host = host.to_ascii_lowercase();
if !host.contains('.') {
return false;
}
let mut labels = host.split('.');
let Some(tld) = labels.next_back() else {
return false;
};
if !is_tld(tld) {
return false;
}
labels.all(is_domain_label)
}
fn is_tld(label: &str) -> bool {
(2..=63).contains(&label.len()) && label.chars().all(|c| c.is_ascii_alphabetic())
}
fn is_domain_label(label: &str) -> bool {
if label.is_empty() || label.len() > 63 {
return false;
}
let mut chars = label.chars();
let Some(first) = chars.next() else {
return false;
};
let Some(last) = label.chars().next_back() else {
return false;
};
first.is_ascii_alphanumeric()
&& last.is_ascii_alphanumeric()
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
pub fn url_preserving_wrap_options<'a>(opts: RtOptions<'a>) -> RtOptions<'a> {
opts.word_separator(textwrap::WordSeparator::AsciiSpace)
.word_splitter(textwrap::WordSplitter::NoHyphenation)
.break_words( false)
}
#[must_use]
pub fn adaptive_wrap_line<'a>(line: &'a Line<'a>, base: RtOptions<'a>) -> Vec<Line<'a>> {
if !line_contains_url_like(line) {
return word_wrap_line(line, base);
}
if line_has_mixed_url_and_non_url_tokens(line) {
mixed_url_wrap_line(line, base)
} else {
word_wrap_line(line, url_preserving_wrap_options(base))
}
}
#[allow(private_bounds)]
pub fn adaptive_wrap_lines<'a, I, L>(
lines: I,
width_or_options: RtOptions<'a>,
) -> Vec<Line<'static>>
where
I: IntoIterator<Item = L>,
L: IntoLineInput<'a>,
{
let base_opts = width_or_options;
let mut out: Vec<Line<'static>> = Vec::new();
for (idx, line) in lines.into_iter().enumerate() {
let line_input = line.into_line_input();
let opts = if idx == 0 {
base_opts.clone()
} else {
base_opts
.clone()
.initial_indent(base_opts.subsequent_indent.clone())
};
let wrapped = adaptive_wrap_line(line_input.as_ref(), opts);
push_owned_lines(&wrapped, &mut out);
}
out
}
#[derive(Debug, Clone)]
pub struct RtOptions<'a> {
pub width: usize,
pub line_ending: textwrap::LineEnding,
pub initial_indent: Line<'a>,
pub subsequent_indent: Line<'a>,
pub break_words: bool,
pub wrap_algorithm: textwrap::WrapAlgorithm,
pub word_separator: textwrap::WordSeparator,
pub word_splitter: textwrap::WordSplitter,
}
impl From<usize> for RtOptions<'_> {
fn from(width: usize) -> Self {
RtOptions::new(width)
}
}
#[allow(dead_code)]
impl<'a> RtOptions<'a> {
pub fn new(width: usize) -> Self {
RtOptions {
width,
line_ending: textwrap::LineEnding::LF,
initial_indent: Line::default(),
subsequent_indent: Line::default(),
break_words: true,
word_separator: textwrap::WordSeparator::new(),
wrap_algorithm: textwrap::WrapAlgorithm::FirstFit,
word_splitter: textwrap::WordSplitter::HyphenSplitter,
}
}
pub fn line_ending(self, line_ending: textwrap::LineEnding) -> Self {
RtOptions {
line_ending,
..self
}
}
pub fn width(self, width: usize) -> Self {
RtOptions { width, ..self }
}
pub fn initial_indent(self, initial_indent: Line<'a>) -> Self {
RtOptions {
initial_indent,
..self
}
}
pub fn subsequent_indent(self, subsequent_indent: Line<'a>) -> Self {
RtOptions {
subsequent_indent,
..self
}
}
pub fn break_words(self, break_words: bool) -> Self {
RtOptions {
break_words,
..self
}
}
pub fn word_separator(self, word_separator: textwrap::WordSeparator) -> RtOptions<'a> {
RtOptions {
word_separator,
..self
}
}
pub fn wrap_algorithm(self, wrap_algorithm: textwrap::WrapAlgorithm) -> RtOptions<'a> {
RtOptions {
wrap_algorithm,
..self
}
}
pub fn word_splitter(self, word_splitter: textwrap::WordSplitter) -> RtOptions<'a> {
RtOptions {
word_splitter,
..self
}
}
}
#[must_use]
pub fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> Vec<Line<'a>>
where
O: Into<RtOptions<'a>>,
{
let (flat, span_bounds) = flatten_line(line);
let rt_opts: RtOptions<'a> = width_or_options.into();
let opts = Options::new(rt_opts.width)
.line_ending(rt_opts.line_ending)
.break_words(rt_opts.break_words)
.wrap_algorithm(rt_opts.wrap_algorithm)
.word_separator(rt_opts.word_separator)
.word_splitter(rt_opts.word_splitter);
let mut out: Vec<Line<'a>> = Vec::new();
let initial_width_available = opts
.width
.saturating_sub(rt_opts.initial_indent.width())
.max(1);
let initial_wrapped = wrap_ranges_trim(&flat, opts.clone().width(initial_width_available));
let Some(first_line_range) = initial_wrapped.first() else {
return vec![rt_opts.initial_indent.clone()];
};
let mut first_line = rt_opts.initial_indent.clone().style(line.style);
{
let sliced = slice_line_spans(line, &span_bounds, first_line_range);
let mut spans = first_line.spans;
spans.append(
&mut sliced
.spans
.into_iter()
.map(|s| s.patch_style(line.style))
.collect(),
);
first_line.spans = spans;
out.push(first_line);
}
let base = first_line_range.end;
let skip_leading_spaces = flat[base..].chars().take_while(|c| *c == ' ').count();
let base = base + skip_leading_spaces;
let subsequent_width_available = opts
.width
.saturating_sub(rt_opts.subsequent_indent.width())
.max(1);
let remaining_wrapped = wrap_ranges_trim(&flat[base..], opts.width(subsequent_width_available));
for r in &remaining_wrapped {
if r.is_empty() {
continue;
}
let mut subsequent_line = rt_opts.subsequent_indent.clone().style(line.style);
let offset_range = (r.start + base)..(r.end + base);
let sliced = slice_line_spans(line, &span_bounds, &offset_range);
let mut spans = subsequent_line.spans;
spans.append(
&mut sliced
.spans
.into_iter()
.map(|s| s.patch_style(line.style))
.collect(),
);
subsequent_line.spans = spans;
out.push(subsequent_line);
}
out
}
#[derive(Clone, Debug)]
struct MixedUrlWord {
range: Range<usize>,
is_url: bool,
}
impl MixedUrlWord {
fn width(&self, text: &str) -> usize {
display_width(&text[self.range.clone()])
}
}
fn mixed_url_wrap_line<'a>(line: &'a Line<'a>, rt_opts: RtOptions<'a>) -> Vec<Line<'a>> {
let (flat, span_bounds) = flatten_line(line);
let initial_width_available = rt_opts
.width
.saturating_sub(rt_opts.initial_indent.width())
.max(1);
let subsequent_width_available = rt_opts
.width
.saturating_sub(rt_opts.subsequent_indent.width())
.max(1);
let ranges = mixed_url_wrap_ranges(&flat, initial_width_available, subsequent_width_available);
let mut out = Vec::new();
for (idx, range) in ranges.iter().enumerate() {
let mut wrapped_line = if idx == 0 {
rt_opts.initial_indent.clone()
} else {
rt_opts.subsequent_indent.clone()
}
.style(line.style);
let sliced = slice_line_spans(line, &span_bounds, range);
let mut spans = wrapped_line.spans;
spans.extend(
sliced
.spans
.into_iter()
.map(|span| span.patch_style(line.style)),
);
wrapped_line.spans = spans;
out.push(wrapped_line);
}
if out.is_empty() {
vec![rt_opts.initial_indent.clone()]
} else {
out
}
}
fn mixed_url_wrap_ranges(
text: &str,
initial_width: usize,
subsequent_width: usize,
) -> Vec<Range<usize>> {
let leading_space_width = text.chars().take_while(|ch| *ch == ' ').count();
let mut words = Vec::new();
let mut cursor = 0usize;
for word in WordSeparator::AsciiSpace.find_words(text) {
let word_start = cursor;
let word_end = word_start + word.word.len();
let trailing_space_end = word_end + word.whitespace.len();
if !word.word.is_empty() {
words.push(MixedUrlWord {
range: word_start..word_end,
is_url: is_url_like_token(word.word),
});
}
cursor = trailing_space_end;
}
let mut lines = Vec::new();
let mut line_start = None;
let mut line_end = 0usize;
let mut line_width = 0usize;
let mut line_limit = initial_width.max(1);
for word in words {
let mut pending = split_mixed_url_word(text, word, line_limit);
let mut pending_idx = 0usize;
while let Some(piece) = pending.get(pending_idx).cloned() {
let empty_line_prefix_width = if line_start.is_none() && lines.is_empty() {
leading_space_width
} else {
0
};
let empty_line_piece_limit = line_limit.saturating_sub(empty_line_prefix_width).max(1);
if line_start.is_none() && !piece.is_url && piece.width(text) > empty_line_piece_limit {
pending.splice(
pending_idx..=pending_idx,
split_mixed_url_word(text, piece, empty_line_piece_limit),
);
continue;
}
let piece_width = piece.width(text);
let inter_word_space = line_start
.map(|_| text[line_end..piece.range.start].len())
.unwrap_or(0);
let fits = if line_start.is_none() {
piece.is_url
|| empty_line_prefix_width + piece_width <= line_limit
|| empty_line_prefix_width >= line_limit
} else {
line_width + inter_word_space + piece_width <= line_limit
};
if fits {
if line_start.is_none() {
let is_first_output_line = lines.is_empty();
let start = if is_first_output_line {
0
} else {
piece.range.start
};
line_start = Some(start);
line_width = if is_first_output_line {
leading_space_width + piece_width
} else {
piece_width
};
} else {
line_width += inter_word_space + piece_width;
}
line_end = piece.range.end;
pending_idx += 1;
continue;
}
if let Some(start) = line_start.take() {
lines.push(start..line_end);
}
line_end = 0;
line_width = 0;
line_limit = subsequent_width.max(1);
}
}
if let Some(start) = line_start {
lines.push(start..line_end);
}
lines
}
fn split_mixed_url_word(text: &str, word: MixedUrlWord, line_limit: usize) -> Vec<MixedUrlWord> {
if word.is_url || word.width(text) <= line_limit {
return vec![word];
}
let source = Word::from(&text[word.range.clone()]);
let mut offset = word.range.start;
let mut pieces = Vec::new();
for piece in source.break_apart(line_limit.max(1)) {
let end = offset + piece.word.len();
pieces.push(MixedUrlWord {
range: offset..end,
is_url: false,
});
offset = end;
}
pieces
}
fn flatten_line(line: &Line<'_>) -> (String, Vec<(Range<usize>, ratatui::style::Style)>) {
let mut flat = String::new();
let mut span_bounds = Vec::new();
let mut acc = 0usize;
for span in &line.spans {
let text = span.content.as_ref();
let start = acc;
flat.push_str(text);
acc += text.len();
span_bounds.push((start..acc, span.style));
}
(flat, span_bounds)
}
#[derive(Debug)]
enum LineInput<'a> {
Borrowed(&'a Line<'a>),
Owned(Line<'a>),
}
impl<'a> LineInput<'a> {
fn as_ref(&self) -> &Line<'a> {
match self {
LineInput::Borrowed(line) => line,
LineInput::Owned(line) => line,
}
}
}
trait IntoLineInput<'a> {
fn into_line_input(self) -> LineInput<'a>;
}
impl<'a> IntoLineInput<'a> for &'a Line<'a> {
fn into_line_input(self) -> LineInput<'a> {
LineInput::Borrowed(self)
}
}
impl<'a> IntoLineInput<'a> for &'a mut Line<'a> {
fn into_line_input(self) -> LineInput<'a> {
LineInput::Borrowed(self)
}
}
impl<'a> IntoLineInput<'a> for Line<'a> {
fn into_line_input(self) -> LineInput<'a> {
LineInput::Owned(self)
}
}
impl<'a> IntoLineInput<'a> for String {
fn into_line_input(self) -> LineInput<'a> {
LineInput::Owned(Line::from(self))
}
}
impl<'a> IntoLineInput<'a> for &'a str {
fn into_line_input(self) -> LineInput<'a> {
LineInput::Owned(Line::from(self))
}
}
impl<'a> IntoLineInput<'a> for Cow<'a, str> {
fn into_line_input(self) -> LineInput<'a> {
LineInput::Owned(Line::from(self))
}
}
impl<'a> IntoLineInput<'a> for Span<'a> {
fn into_line_input(self) -> LineInput<'a> {
LineInput::Owned(Line::from(self))
}
}
impl<'a> IntoLineInput<'a> for Vec<Span<'a>> {
fn into_line_input(self) -> LineInput<'a> {
LineInput::Owned(Line::from(self))
}
}
#[allow(private_bounds)] pub fn word_wrap_lines<'a, I, O, L>(lines: I, width_or_options: O) -> Vec<Line<'static>>
where
I: IntoIterator<Item = L>,
L: IntoLineInput<'a>,
O: Into<RtOptions<'a>>,
{
let base_opts: RtOptions<'a> = width_or_options.into();
let mut out: Vec<Line<'static>> = Vec::new();
for (idx, line) in lines.into_iter().enumerate() {
let line_input = line.into_line_input();
let opts = if idx == 0 {
base_opts.clone()
} else {
let mut o = base_opts.clone();
let sub = o.subsequent_indent.clone();
o = o.initial_indent(sub);
o
};
let wrapped = word_wrap_line(line_input.as_ref(), opts);
push_owned_lines(&wrapped, &mut out);
}
out
}
#[allow(dead_code)]
pub fn word_wrap_lines_borrowed<'a, I, O>(lines: I, width_or_options: O) -> Vec<Line<'a>>
where
I: IntoIterator<Item = &'a Line<'a>>,
O: Into<RtOptions<'a>>,
{
let base_opts: RtOptions<'a> = width_or_options.into();
let mut out: Vec<Line<'a>> = Vec::new();
let mut first = true;
for line in lines.into_iter() {
let opts = if first {
base_opts.clone()
} else {
base_opts
.clone()
.initial_indent(base_opts.subsequent_indent.clone())
};
out.extend(word_wrap_line(line, opts));
first = false;
}
out
}
fn slice_line_spans<'a>(
original: &'a Line<'a>,
span_bounds: &[(Range<usize>, ratatui::style::Style)],
range: &Range<usize>,
) -> Line<'a> {
let start_byte = range.start;
let end_byte = range.end;
let mut acc: Vec<Span<'a>> = Vec::new();
for (i, (range, style)) in span_bounds.iter().enumerate() {
let s = range.start;
let e = range.end;
if e <= start_byte {
continue;
}
if s >= end_byte {
break;
}
let seg_start = start_byte.max(s);
let seg_end = end_byte.min(e);
if seg_end > seg_start {
let local_start = seg_start - s;
let local_end = seg_end - s;
let content = original.spans[i].content.as_ref();
let slice = &content[local_start..local_end];
acc.push(Span {
style: *style,
content: std::borrow::Cow::Borrowed(slice),
});
}
if e >= end_byte {
break;
}
}
Line {
style: original.style,
alignment: original.alignment,
spans: acc,
}
}