#![forbid(unsafe_code)]
use crate::TextMeasurement;
use crate::grapheme_width;
use crate::segment::{Segment, SegmentLine, SegmentLines, split_into_lines};
use crate::wrap::{WrapMode, graphemes, truncate_to_width_with_info};
use ftui_style::Style;
use std::borrow::Cow;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Span<'a> {
pub content: Cow<'a, str>,
pub style: Option<Style>,
pub link: Option<Cow<'a, str>>,
}
impl<'a> Span<'a> {
#[inline]
#[must_use]
pub fn raw(content: impl Into<Cow<'a, str>>) -> Self {
Self {
content: content.into(),
style: None,
link: None,
}
}
#[inline]
#[must_use]
pub fn styled(content: impl Into<Cow<'a, str>>, style: Style) -> Self {
Self {
content: content.into(),
style: Some(style),
link: None,
}
}
#[inline]
#[must_use]
pub fn link(mut self, link: impl Into<Cow<'a, str>>) -> Self {
self.link = Some(link.into());
self
}
#[inline]
#[must_use]
pub fn as_str(&self) -> &str {
&self.content
}
#[inline]
#[must_use]
pub fn width(&self) -> usize {
crate::display_width(&self.content)
}
#[must_use]
pub fn split_at_cell(&self, cell_pos: usize) -> (Self, Self) {
if self.content.is_empty() || cell_pos == 0 {
return (
Self {
content: Cow::Borrowed(""),
style: self.style,
link: self.link.clone(),
},
self.clone(),
);
}
let total_width = self.width();
if cell_pos >= total_width {
return (
self.clone(),
Self {
content: Cow::Borrowed(""),
style: self.style,
link: self.link.clone(),
},
);
}
let (byte_pos, _actual_width) = find_cell_boundary(&self.content, cell_pos);
let (left_cow, right_cow) = match &self.content {
Cow::Borrowed(s) => {
let (l, r) = s.split_at(byte_pos);
(Cow::Borrowed(l), Cow::Borrowed(r))
}
Cow::Owned(s) => {
let (l, r) = s.split_at(byte_pos);
(Cow::Owned(l.to_string()), Cow::Owned(r.to_string()))
}
};
(
Self {
content: left_cow,
style: self.style,
link: self.link.clone(),
},
Self {
content: right_cow,
style: self.style,
link: self.link.clone(),
},
)
}
#[must_use]
pub fn measurement(&self) -> TextMeasurement {
let width = self.width();
TextMeasurement {
minimum: width,
maximum: width,
}
}
#[inline]
#[must_use]
pub fn is_empty(&self) -> bool {
self.content.is_empty()
}
#[inline]
#[must_use]
pub fn with_style(mut self, style: Style) -> Self {
self.style = Some(style);
self
}
#[inline]
#[must_use]
pub fn into_segment(self) -> Segment<'a> {
let mut seg = match self.style {
Some(style) => Segment::styled(self.content, style),
None => Segment::text(self.content),
};
seg.link = self.link;
seg
}
#[must_use]
pub fn into_owned(self) -> Span<'static> {
Span {
content: Cow::Owned(self.content.into_owned()),
style: self.style,
link: self.link.map(|l| Cow::Owned(l.into_owned())),
}
}
}
impl<'a> From<&'a str> for Span<'a> {
fn from(s: &'a str) -> Self {
Self::raw(s)
}
}
impl From<String> for Span<'static> {
fn from(s: String) -> Self {
Self::raw(s)
}
}
impl<'a> From<Segment<'a>> for Span<'a> {
fn from(seg: Segment<'a>) -> Self {
Self {
content: seg.text,
style: seg.style,
link: seg.link,
}
}
}
impl Default for Span<'_> {
fn default() -> Self {
Self::raw("")
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct Text<'a> {
lines: Vec<Line<'a>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct Line<'a> {
spans: Vec<Span<'a>>,
}
impl<'a> Line<'a> {
#[inline]
#[must_use]
pub const fn new() -> Self {
Self { spans: Vec::new() }
}
#[must_use]
pub fn from_spans(spans: impl IntoIterator<Item = Span<'a>>) -> Self {
Self {
spans: spans.into_iter().collect(),
}
}
#[inline]
#[must_use]
pub fn raw(content: impl Into<Cow<'a, str>>) -> Self {
Self {
spans: vec![Span::raw(content)],
}
}
#[inline]
#[must_use]
pub fn styled(content: impl Into<Cow<'a, str>>, style: Style) -> Self {
Self {
spans: vec![Span::styled(content, style)],
}
}
#[inline]
#[must_use]
pub fn is_empty(&self) -> bool {
self.spans.is_empty() || self.spans.iter().all(|s| s.is_empty())
}
#[inline]
#[must_use]
pub fn len(&self) -> usize {
self.spans.len()
}
#[inline]
#[must_use]
pub fn width(&self) -> usize {
self.spans.iter().map(|s| s.width()).sum()
}
#[must_use]
pub fn measurement(&self) -> TextMeasurement {
let width = self.width();
TextMeasurement {
minimum: width,
maximum: width,
}
}
#[inline]
#[must_use]
pub fn spans(&self) -> &[Span<'a>] {
&self.spans
}
#[inline]
pub fn push_span(&mut self, span: Span<'a>) {
self.spans.push(span);
}
#[inline]
#[must_use]
pub fn with_span(mut self, span: Span<'a>) -> Self {
self.push_span(span);
self
}
pub fn apply_base_style(&mut self, base: Style) {
for span in &mut self.spans {
span.style = Some(match span.style {
Some(existing) => existing.merge(&base),
None => base,
});
}
}
#[must_use]
pub fn to_plain_text(&self) -> String {
self.spans.iter().map(|s| s.as_str()).collect()
}
#[must_use]
pub fn wrap(&self, width: usize, mode: WrapMode) -> Vec<Line<'a>> {
if mode == WrapMode::None || width == 0 {
return vec![self.clone()];
}
if self.is_empty() {
return vec![Line::new()];
}
match mode {
WrapMode::None => vec![self.clone()],
WrapMode::Char => wrap_line_chars(self, width),
WrapMode::Word | WrapMode::Optimal => wrap_line_words(self, width, false),
WrapMode::WordChar => wrap_line_words(self, width, true),
}
}
#[must_use]
pub fn into_segments(self) -> Vec<Segment<'a>> {
self.spans.into_iter().map(|s| s.into_segment()).collect()
}
#[must_use]
pub fn into_segment_line(self) -> SegmentLine<'a> {
SegmentLine::from_segments(self.into_segments())
}
pub fn iter(&self) -> impl Iterator<Item = &Span<'a>> {
self.spans.iter()
}
}
impl<'a> From<Span<'a>> for Line<'a> {
fn from(span: Span<'a>) -> Self {
Self { spans: vec![span] }
}
}
impl<'a> From<&'a str> for Line<'a> {
fn from(s: &'a str) -> Self {
Self::raw(s)
}
}
impl From<String> for Line<'static> {
fn from(s: String) -> Self {
Self::raw(s)
}
}
impl<'a> IntoIterator for Line<'a> {
type Item = Span<'a>;
type IntoIter = std::vec::IntoIter<Span<'a>>;
fn into_iter(self) -> Self::IntoIter {
self.spans.into_iter()
}
}
impl<'a> IntoIterator for &'a Line<'a> {
type Item = &'a Span<'a>;
type IntoIter = std::slice::Iter<'a, Span<'a>>;
fn into_iter(self) -> Self::IntoIter {
self.spans.iter()
}
}
impl<'a> Text<'a> {
#[inline]
#[must_use]
pub const fn new() -> Self {
Self { lines: Vec::new() }
}
#[must_use]
pub fn raw(content: impl Into<Cow<'a, str>>) -> Self {
let content = content.into();
if content.is_empty() {
return Self::new();
}
let lines: Vec<Line<'a>> = match content {
Cow::Borrowed(s) => s.split('\n').map(Line::raw).collect(),
Cow::Owned(s) => s
.split('\n')
.map(|line| Line::raw(line.to_string()))
.collect(),
};
Self { lines }
}
#[must_use]
pub fn styled(content: impl Into<Cow<'a, str>>, style: Style) -> Self {
let content = content.into();
if content.is_empty() {
return Self::new();
}
let lines: Vec<Line<'a>> = match content {
Cow::Borrowed(s) => s.split('\n').map(|l| Line::styled(l, style)).collect(),
Cow::Owned(s) => s
.split('\n')
.map(|l| Line::styled(l.to_string(), style))
.collect(),
};
Self { lines }
}
#[inline]
#[must_use]
pub fn from_line(line: Line<'a>) -> Self {
Self { lines: vec![line] }
}
#[must_use]
pub fn from_lines(lines: impl IntoIterator<Item = Line<'a>>) -> Self {
Self {
lines: lines.into_iter().collect(),
}
}
#[must_use]
pub fn from_spans(spans: impl IntoIterator<Item = Span<'a>>) -> Self {
Self {
lines: vec![Line::from_spans(spans)],
}
}
#[must_use]
pub fn from_segments(segments: impl IntoIterator<Item = Segment<'a>>) -> Self {
let segment_lines = split_into_lines(segments);
let lines: Vec<Line<'a>> = segment_lines
.into_iter()
.map(|seg_line| Line::from_spans(seg_line.into_iter().map(Span::from)))
.collect();
Self { lines }
}
#[inline]
#[must_use]
pub fn is_empty(&self) -> bool {
self.lines.is_empty() || self.lines.iter().all(|l| l.is_empty())
}
#[inline]
#[must_use]
pub fn height(&self) -> usize {
self.lines.len()
}
#[inline]
#[must_use]
pub fn height_as_u16(&self) -> u16 {
self.lines.len().try_into().unwrap_or(u16::MAX)
}
#[inline]
#[must_use]
pub fn width(&self) -> usize {
self.lines.iter().map(|l| l.width()).max().unwrap_or(0)
}
#[must_use]
pub fn measurement(&self) -> TextMeasurement {
let width = self.width();
TextMeasurement {
minimum: width,
maximum: width,
}
}
#[inline]
#[must_use]
pub fn lines(&self) -> &[Line<'a>] {
&self.lines
}
#[inline]
#[must_use]
pub fn style(&self) -> Option<Style> {
self.lines
.first()
.and_then(|line| line.spans().first())
.and_then(|span| span.style)
}
#[inline]
pub fn push_line(&mut self, line: Line<'a>) {
self.lines.push(line);
}
#[inline]
#[must_use]
pub fn with_line(mut self, line: Line<'a>) -> Self {
self.push_line(line);
self
}
pub fn push_span(&mut self, span: Span<'a>) {
if self.lines.is_empty() {
self.lines.push(Line::new());
}
if let Some(last) = self.lines.last_mut() {
last.push_span(span);
}
}
#[must_use]
pub fn with_span(mut self, span: Span<'a>) -> Self {
self.push_span(span);
self
}
pub fn apply_base_style(&mut self, base: Style) {
for line in &mut self.lines {
line.apply_base_style(base);
}
}
#[must_use]
pub fn with_base_style(mut self, base: Style) -> Self {
self.apply_base_style(base);
self
}
#[must_use]
pub fn to_plain_text(&self) -> String {
self.lines
.iter()
.map(|l| l.to_plain_text())
.collect::<Vec<_>>()
.join("\n")
}
#[must_use]
pub fn into_segment_lines(self) -> SegmentLines<'a> {
SegmentLines::from_lines(
self.lines
.into_iter()
.map(|l| l.into_segment_line())
.collect(),
)
}
pub fn iter(&self) -> impl Iterator<Item = &Line<'a>> {
self.lines.iter()
}
pub fn truncate(&mut self, max_width: usize, ellipsis: Option<&str>) {
let ellipsis_width = ellipsis.map(crate::display_width).unwrap_or(0);
for line in &mut self.lines {
let line_width = line.width();
if line_width <= max_width {
continue;
}
let (content_width, use_ellipsis) = if ellipsis.is_some() && max_width >= ellipsis_width
{
(max_width - ellipsis_width, true)
} else {
(max_width, false)
};
let mut remaining = content_width;
let mut new_spans = Vec::new();
for span in &line.spans {
if remaining == 0 {
break;
}
let span_width = span.width();
if span_width <= remaining {
new_spans.push(span.clone());
remaining -= span_width;
} else {
let (truncated, _) = truncate_to_width_with_info(&span.content, remaining);
if !truncated.is_empty() {
new_spans.push(Span {
content: Cow::Owned(truncated.to_string()),
style: span.style,
link: span.link.clone(),
});
}
remaining = 0;
}
}
if use_ellipsis
&& line_width > max_width
&& let Some(e) = ellipsis
{
new_spans.push(Span::raw(e.to_string()));
}
line.spans = new_spans;
}
}
#[must_use]
pub fn truncated(&self, max_width: usize, ellipsis: Option<&str>) -> Self {
let mut text = self.clone();
text.truncate(max_width, ellipsis);
text
}
}
fn find_cell_boundary(text: &str, target_cells: usize) -> (usize, usize) {
let mut current_cells = 0;
let mut byte_pos = 0;
for grapheme in graphemes(text) {
let grapheme_width = grapheme_width(grapheme);
if current_cells + grapheme_width > target_cells {
break;
}
current_cells += grapheme_width;
byte_pos += grapheme.len();
if current_cells >= target_cells {
break;
}
}
(byte_pos, current_cells)
}
fn span_is_whitespace(span: &Span<'_>) -> bool {
span.as_str()
.graphemes(true)
.all(|g| g.chars().all(|c| c.is_whitespace()))
}
fn trim_span_start<'a>(span: Span<'a>) -> Span<'a> {
let text = span.as_str();
let mut start = 0;
let mut found = false;
for (idx, grapheme) in text.grapheme_indices(true) {
if grapheme.chars().all(|c| c.is_whitespace()) {
start = idx + grapheme.len();
continue;
}
found = true;
break;
}
if !found {
return Span::raw("");
}
Span {
content: Cow::Owned(text[start..].to_string()),
style: span.style,
link: span.link,
}
}
fn trim_span_end<'a>(span: Span<'a>) -> Span<'a> {
let text = span.as_str();
let mut end = text.len();
let mut found = false;
for (idx, grapheme) in text.grapheme_indices(true).rev() {
if grapheme.chars().all(|c| c.is_whitespace()) {
end = idx;
continue;
}
found = true;
break;
}
if !found {
return Span::raw("");
}
Span {
content: Cow::Owned(text[..end].to_string()),
style: span.style,
link: span.link,
}
}
fn trim_line_trailing<'a>(mut line: Line<'a>) -> Line<'a> {
while let Some(last) = line.spans.last().cloned() {
let trimmed = trim_span_end(last);
if trimmed.is_empty() {
line.spans.pop();
continue;
}
let len = line.spans.len();
if len > 0 {
line.spans[len - 1] = trimmed;
}
break;
}
line
}
fn push_span_merged<'a>(line: &mut Line<'a>, span: Span<'a>) {
if span.is_empty() {
return;
}
if let Some(last) = line.spans.last_mut()
&& last.style == span.style
&& last.link == span.link
{
let mut merged = String::with_capacity(last.as_str().len() + span.as_str().len());
merged.push_str(last.as_str());
merged.push_str(span.as_str());
last.content = Cow::Owned(merged);
return;
}
line.spans.push(span);
}
fn split_span_words<'a>(span: &Span<'a>) -> Vec<Span<'a>> {
let (text, borrowed_base): (&str, Option<&'a str>) = match &span.content {
Cow::Borrowed(s) => (*s, Some(*s)),
Cow::Owned(s) => (s.as_str(), None),
};
let mut start = 0;
let mut in_whitespace = false;
let mut segments = Vec::new();
for (idx, grapheme) in text.grapheme_indices(true) {
let is_ws = grapheme.chars().all(crate::wrap::is_breaking_whitespace);
if idx == 0 {
in_whitespace = is_ws;
}
if is_ws != in_whitespace {
let sub = &text[start..idx];
let content = match borrowed_base {
Some(base) => Cow::Borrowed(&base[start..idx]),
None => Cow::Owned(sub.to_string()),
};
segments.push(Span {
content,
style: span.style,
link: span.link.clone(),
});
start = idx;
in_whitespace = is_ws;
}
}
if start < text.len() {
let sub = &text[start..];
let content = match borrowed_base {
Some(base) => Cow::Borrowed(&base[start..]),
None => Cow::Owned(sub.to_string()),
};
segments.push(Span {
content,
style: span.style,
link: span.link.clone(),
});
}
segments
}
fn wrap_line_chars<'a>(line: &Line<'a>, width: usize) -> Vec<Line<'a>> {
let mut lines = Vec::new();
let mut current = Line::new();
let mut current_width = 0;
for span in line.spans.iter().cloned() {
let mut remaining = span;
while !remaining.is_empty() {
if current_width >= width && !current.is_empty() {
lines.push(trim_line_trailing(current));
current = Line::new();
current_width = 0;
}
let available = width.saturating_sub(current_width).max(1);
let span_width = remaining.width();
if span_width <= available {
current_width += span_width;
push_span_merged(&mut current, remaining);
break;
}
let (left, right) = remaining.split_at_cell(available);
let (left, right) = if left.is_empty() && current.is_empty() && !remaining.is_empty() {
let first_w = remaining
.as_str()
.graphemes(true)
.next()
.map(grapheme_width)
.unwrap_or(1);
remaining.split_at_cell(first_w.max(1))
} else {
(left, right)
};
if !left.is_empty() {
push_span_merged(&mut current, left);
}
lines.push(trim_line_trailing(current));
current = Line::new();
current_width = 0;
remaining = right;
}
}
if !current.is_empty() || lines.is_empty() {
lines.push(trim_line_trailing(current));
}
lines
}
fn wrap_line_words<'a>(line: &Line<'a>, width: usize, char_fallback: bool) -> Vec<Line<'a>> {
let mut pieces: Vec<Span<'a>> = Vec::new();
for span in &line.spans {
pieces.extend(split_span_words(span));
}
let mut lines = Vec::new();
let mut current = Line::new();
let mut current_width = 0;
let mut first_line = true;
for piece in pieces {
let piece_width = piece.width();
let is_ws = span_is_whitespace(&piece);
if current_width + piece_width <= width {
if current_width == 0 && !first_line && is_ws {
continue;
}
current_width += piece_width;
push_span_merged(&mut current, piece);
continue;
}
if !current.is_empty() {
lines.push(trim_line_trailing(current));
current = Line::new();
current_width = 0;
first_line = false;
}
if piece_width > width {
if char_fallback {
let mut remaining = piece;
while !remaining.is_empty() {
if current_width >= width && !current.is_empty() {
lines.push(trim_line_trailing(current));
current = Line::new();
current_width = 0;
first_line = false;
}
let available = width.saturating_sub(current_width).max(1);
let (left, right) = remaining.split_at_cell(available);
let (left, right) =
if left.is_empty() && current.is_empty() && !remaining.is_empty() {
let first_w = remaining
.as_str()
.graphemes(true)
.next()
.map(grapheme_width)
.unwrap_or(1);
remaining.split_at_cell(first_w.max(1))
} else {
(left, right)
};
let mut left = left;
if current_width == 0 && !first_line {
left = trim_span_start(left);
}
if !left.is_empty() {
current_width += left.width();
push_span_merged(&mut current, left);
}
if current_width >= width && !current.is_empty() {
lines.push(trim_line_trailing(current));
current = Line::new();
current_width = 0;
first_line = false;
}
remaining = right;
}
} else if !is_ws {
let mut trimmed = piece;
if !first_line {
trimmed = trim_span_start(trimmed);
}
if !trimmed.is_empty() {
push_span_merged(&mut current, trimmed);
}
lines.push(trim_line_trailing(current));
current = Line::new();
current_width = 0;
first_line = false;
}
continue;
}
let mut trimmed = piece;
if !first_line {
trimmed = trim_span_start(trimmed);
}
if !trimmed.is_empty() {
current_width += trimmed.width();
push_span_merged(&mut current, trimmed);
}
}
if !current.is_empty() || lines.is_empty() {
lines.push(trim_line_trailing(current));
}
lines
}
impl<'a> From<&'a str> for Text<'a> {
fn from(s: &'a str) -> Self {
Self::raw(s)
}
}
impl From<String> for Text<'static> {
fn from(s: String) -> Self {
Self::raw(s)
}
}
impl<'a> From<Line<'a>> for Text<'a> {
fn from(line: Line<'a>) -> Self {
Self::from_line(line)
}
}
impl<'a> FromIterator<Span<'a>> for Text<'a> {
fn from_iter<I: IntoIterator<Item = Span<'a>>>(iter: I) -> Self {
Self::from_spans(iter)
}
}
impl<'a> FromIterator<Line<'a>> for Text<'a> {
fn from_iter<I: IntoIterator<Item = Line<'a>>>(iter: I) -> Self {
Self::from_lines(iter)
}
}
impl<'a> IntoIterator for Text<'a> {
type Item = Line<'a>;
type IntoIter = std::vec::IntoIter<Line<'a>>;
fn into_iter(self) -> Self::IntoIter {
self.lines.into_iter()
}
}
impl<'a> IntoIterator for &'a Text<'a> {
type Item = &'a Line<'a>;
type IntoIter = std::slice::Iter<'a, Line<'a>>;
fn into_iter(self) -> Self::IntoIter {
self.lines.iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_style::StyleFlags;
#[test]
fn span_raw_creates_unstyled() {
let span = Span::raw("hello");
assert_eq!(span.as_str(), "hello");
assert!(span.style.is_none());
}
#[test]
fn span_styled_creates_styled() {
let style = Style::new().bold();
let span = Span::styled("hello", style);
assert_eq!(span.as_str(), "hello");
assert_eq!(span.style, Some(style));
}
#[test]
fn span_width_ascii() {
let span = Span::raw("hello");
assert_eq!(span.width(), 5);
}
#[test]
fn span_width_cjk() {
let span = Span::raw("ä½ å¥½");
assert_eq!(span.width(), 4);
}
#[test]
fn span_into_segment() {
let style = Style::new().bold();
let span = Span::styled("hello", style);
let seg = span.into_segment();
assert_eq!(seg.as_str(), "hello");
assert_eq!(seg.style, Some(style));
}
#[test]
fn line_empty() {
let line = Line::new();
assert!(line.is_empty());
assert_eq!(line.width(), 0);
}
#[test]
fn line_raw() {
let line = Line::raw("hello world");
assert_eq!(line.width(), 11);
assert_eq!(line.to_plain_text(), "hello world");
}
#[test]
fn line_styled() {
let style = Style::new().bold();
let line = Line::styled("hello", style);
assert_eq!(line.spans()[0].style, Some(style));
}
#[test]
fn line_from_spans() {
let line = Line::from_spans([Span::raw("hello "), Span::raw("world")]);
assert_eq!(line.len(), 2);
assert_eq!(line.width(), 11);
assert_eq!(line.to_plain_text(), "hello world");
}
#[test]
fn line_push_span() {
let mut line = Line::raw("hello ");
line.push_span(Span::raw("world"));
assert_eq!(line.len(), 2);
assert_eq!(line.to_plain_text(), "hello world");
}
#[test]
fn line_apply_base_style() {
let base = Style::new().bold();
let mut line = Line::from_spans([
Span::raw("hello"),
Span::styled("world", Style::new().italic()),
]);
line.apply_base_style(base);
assert!(line.spans()[0].style.unwrap().has_attr(StyleFlags::BOLD));
let second_style = line.spans()[1].style.unwrap();
assert!(second_style.has_attr(StyleFlags::BOLD));
assert!(second_style.has_attr(StyleFlags::ITALIC));
}
#[test]
fn line_wrap_preserves_styles_word() {
let bold = Style::new().bold();
let italic = Style::new().italic();
let line = Line::from_spans([Span::styled("Hello", bold), Span::styled(" world", italic)]);
let wrapped = line.wrap(6, WrapMode::Word);
assert_eq!(wrapped.len(), 2);
assert_eq!(wrapped[0].spans()[0].as_str(), "Hello");
assert_eq!(wrapped[0].spans()[0].style, Some(bold));
assert_eq!(wrapped[1].spans()[0].as_str(), "world");
assert_eq!(wrapped[1].spans()[0].style, Some(italic));
}
#[test]
fn text_empty() {
let text = Text::new();
assert!(text.is_empty());
assert_eq!(text.height(), 0);
assert_eq!(text.width(), 0);
}
#[test]
fn text_raw_single_line() {
let text = Text::raw("hello world");
assert_eq!(text.height(), 1);
assert_eq!(text.width(), 11);
assert_eq!(text.to_plain_text(), "hello world");
}
#[test]
fn text_raw_multiline() {
let text = Text::raw("line 1\nline 2\nline 3");
assert_eq!(text.height(), 3);
assert_eq!(text.to_plain_text(), "line 1\nline 2\nline 3");
}
#[test]
fn text_styled() {
let style = Style::new().bold();
let text = Text::styled("hello", style);
assert_eq!(text.lines()[0].spans()[0].style, Some(style));
}
#[test]
fn text_from_spans() {
let text = Text::from_spans([Span::raw("hello "), Span::raw("world")]);
assert_eq!(text.height(), 1);
assert_eq!(text.to_plain_text(), "hello world");
}
#[test]
fn text_from_lines() {
let text = Text::from_lines([Line::raw("line 1"), Line::raw("line 2")]);
assert_eq!(text.height(), 2);
assert_eq!(text.to_plain_text(), "line 1\nline 2");
}
#[test]
fn text_push_line() {
let mut text = Text::raw("line 1");
text.push_line(Line::raw("line 2"));
assert_eq!(text.height(), 2);
}
#[test]
fn text_push_span() {
let mut text = Text::raw("hello ");
text.push_span(Span::raw("world"));
assert_eq!(text.to_plain_text(), "hello world");
}
#[test]
fn text_apply_base_style() {
let base = Style::new().bold();
let mut text = Text::from_lines([
Line::raw("line 1"),
Line::styled("line 2", Style::new().italic()),
]);
text.apply_base_style(base);
assert!(
text.lines()[0].spans()[0]
.style
.unwrap()
.has_attr(StyleFlags::BOLD)
);
let second_style = text.lines()[1].spans()[0].style.unwrap();
assert!(second_style.has_attr(StyleFlags::BOLD));
assert!(second_style.has_attr(StyleFlags::ITALIC));
}
#[test]
fn text_width_multiline() {
let text = Text::raw("short\nlonger line\nmed");
assert_eq!(text.width(), 11); }
#[test]
fn truncate_no_change_if_fits() {
let mut text = Text::raw("hello");
text.truncate(10, None);
assert_eq!(text.to_plain_text(), "hello");
}
#[test]
fn truncate_simple() {
let mut text = Text::raw("hello world");
text.truncate(5, None);
assert_eq!(text.to_plain_text(), "hello");
}
#[test]
fn truncate_with_ellipsis() {
let mut text = Text::raw("hello world");
text.truncate(8, Some("..."));
assert_eq!(text.to_plain_text(), "hello...");
}
#[test]
fn truncate_multiline() {
let mut text = Text::raw("hello world\nfoo bar baz");
text.truncate(8, Some("..."));
assert_eq!(text.to_plain_text(), "hello...\nfoo b...");
}
#[test]
fn truncate_preserves_style() {
let style = Style::new().bold();
let mut text = Text::styled("hello world", style);
text.truncate(5, None);
assert_eq!(text.lines()[0].spans()[0].style, Some(style));
}
#[test]
fn truncate_cjk() {
let mut text = Text::raw("ä½ å¥½ä¸–ç•Œ"); text.truncate(4, None);
assert_eq!(text.to_plain_text(), "ä½ å¥½");
}
#[test]
fn truncate_cjk_odd_width() {
let mut text = Text::raw("ä½ å¥½ä¸–ç•Œ"); text.truncate(5, None); assert_eq!(text.to_plain_text(), "ä½ å¥½");
}
#[test]
fn text_from_str() {
let text: Text = "hello".into();
assert_eq!(text.to_plain_text(), "hello");
}
#[test]
fn text_from_string() {
let text: Text = String::from("hello").into();
assert_eq!(text.to_plain_text(), "hello");
}
#[test]
fn text_from_empty_string_is_empty() {
let text: Text = String::new().into();
assert!(text.is_empty());
assert_eq!(text.height(), 0);
assert_eq!(text.width(), 0);
}
#[test]
fn text_from_empty_line_preserves_single_empty_line() {
let text: Text = Line::new().into();
assert_eq!(text.height(), 1);
assert!(text.lines()[0].is_empty());
assert_eq!(text.width(), 0);
}
#[test]
fn text_from_lines_empty_iter_is_empty() {
let text = Text::from_lines(Vec::<Line>::new());
assert!(text.is_empty());
assert_eq!(text.height(), 0);
}
#[test]
fn text_from_str_preserves_empty_middle_line() {
let text: Text = "a\n\nb".into();
assert_eq!(text.height(), 3);
assert_eq!(text.lines()[0].to_plain_text(), "a");
assert!(text.lines()[1].is_empty());
assert_eq!(text.lines()[2].to_plain_text(), "b");
assert_eq!(text.to_plain_text(), "a\n\nb");
}
#[test]
fn text_into_segment_lines() {
let text = Text::raw("line 1\nline 2");
let seg_lines = text.into_segment_lines();
assert_eq!(seg_lines.len(), 2);
}
#[test]
fn line_into_iter() {
let line = Line::from_spans([Span::raw("a"), Span::raw("b")]);
let collected: Vec<_> = line.into_iter().collect();
assert_eq!(collected.len(), 2);
}
#[test]
fn text_into_iter() {
let text = Text::from_lines([Line::raw("a"), Line::raw("b")]);
let collected: Vec<_> = text.into_iter().collect();
assert_eq!(collected.len(), 2);
}
#[test]
fn text_collect_from_spans() {
let text: Text = [Span::raw("a"), Span::raw("b")].into_iter().collect();
assert_eq!(text.height(), 1);
assert_eq!(text.to_plain_text(), "ab");
}
#[test]
fn text_collect_from_lines() {
let text: Text = [Line::raw("a"), Line::raw("b")].into_iter().collect();
assert_eq!(text.height(), 2);
}
#[test]
fn empty_string_creates_empty_text() {
let text = Text::raw("");
assert!(text.is_empty());
}
#[test]
fn single_newline_creates_two_empty_lines() {
let text = Text::raw("\n");
assert_eq!(text.height(), 2);
assert!(text.lines()[0].is_empty());
assert!(text.lines()[1].is_empty());
}
#[test]
fn trailing_newline() {
let text = Text::raw("hello\n");
assert_eq!(text.height(), 2);
assert_eq!(text.lines()[0].to_plain_text(), "hello");
assert!(text.lines()[1].is_empty());
}
#[test]
fn leading_newline() {
let text = Text::raw("\nhello");
assert_eq!(text.height(), 2);
assert!(text.lines()[0].is_empty());
assert_eq!(text.lines()[1].to_plain_text(), "hello");
}
#[test]
fn line_with_span_ownership() {
let s = String::from("hello");
let line = Line::raw(s.clone());
drop(s); assert_eq!(line.to_plain_text(), "hello"); }
#[test]
fn span_cow_borrowed_from_static() {
let span = Span::raw("static");
assert!(matches!(span.content, Cow::Borrowed(_)));
}
#[test]
fn span_cow_owned_from_string() {
let span = Span::raw(String::from("owned"));
assert!(matches!(span.content, Cow::Owned(_)));
}
#[test]
fn span_into_owned_converts_borrowed() {
let span = Span::raw("borrowed");
assert!(matches!(span.content, Cow::Borrowed(_)));
let owned = span.into_owned();
assert!(matches!(owned.content, Cow::Owned(_)));
assert_eq!(owned.as_str(), "borrowed");
}
#[test]
fn span_with_link_into_owned() {
let span = Span::raw("text").link("https://example.com");
let owned = span.into_owned();
assert!(owned.link.is_some());
assert!(matches!(owned.link.as_ref().unwrap(), Cow::Owned(_)));
}
#[test]
fn span_link_method() {
let span = Span::raw("click me").link("https://example.com");
assert_eq!(span.link.as_deref(), Some("https://example.com"));
}
#[test]
fn span_measurement() {
let span = Span::raw("hello");
let m = span.measurement();
assert_eq!(m.minimum, 5);
assert_eq!(m.maximum, 5);
}
#[test]
fn span_is_empty() {
assert!(Span::raw("").is_empty());
assert!(!Span::raw("x").is_empty());
}
#[test]
fn span_default_is_empty() {
let span = Span::default();
assert!(span.is_empty());
assert!(span.style.is_none());
assert!(span.link.is_none());
}
#[test]
fn span_with_style() {
let style = Style::new().bold();
let span = Span::raw("text").with_style(style);
assert_eq!(span.style, Some(style));
}
#[test]
fn span_from_segment() {
let style = Style::new().italic();
let seg = Segment::styled("hello", style);
let span: Span = seg.into();
assert_eq!(span.as_str(), "hello");
assert_eq!(span.style, Some(style));
}
#[test]
fn span_debug_impl() {
let span = Span::raw("test");
let debug = format!("{:?}", span);
assert!(debug.contains("Span"));
assert!(debug.contains("test"));
}
#[test]
fn line_measurement() {
let line = Line::raw("hello world");
let m = line.measurement();
assert_eq!(m.minimum, 11);
assert_eq!(m.maximum, 11);
}
#[test]
fn line_from_empty_string_is_empty() {
let line: Line = String::new().into();
assert!(line.is_empty());
assert_eq!(line.width(), 0);
}
#[test]
fn line_width_combining_mark_is_single_cell() {
let line = Line::raw("e\u{301}");
assert_eq!(line.width(), 1);
}
#[test]
fn line_wrap_handles_wide_grapheme_with_tiny_width() {
let line = Line::raw("ä½ å¥½");
let wrapped = line.wrap(1, WrapMode::Char);
assert_eq!(wrapped.len(), 2);
assert_eq!(wrapped[0].to_plain_text(), "ä½ ");
assert_eq!(wrapped[1].to_plain_text(), "好");
}
#[test]
fn line_iter() {
let line = Line::from_spans([Span::raw("a"), Span::raw("b"), Span::raw("c")]);
let collected: Vec<_> = line.iter().collect();
assert_eq!(collected.len(), 3);
}
#[test]
fn line_into_segments() {
let style = Style::new().bold();
let line = Line::from_spans([Span::raw("hello"), Span::styled(" world", style)]);
let segments = line.into_segments();
assert_eq!(segments.len(), 2);
assert_eq!(segments[0].style, None);
assert_eq!(segments[1].style, Some(style));
}
#[test]
fn line_into_segment_line() {
let line = Line::raw("test");
let seg_line = line.into_segment_line();
assert_eq!(seg_line.to_plain_text(), "test");
}
#[test]
fn line_with_span_builder() {
let line = Line::raw("hello ").with_span(Span::raw("world"));
assert_eq!(line.to_plain_text(), "hello world");
}
#[test]
fn line_from_span() {
let span = Span::styled("test", Style::new().bold());
let line: Line = span.into();
assert_eq!(line.to_plain_text(), "test");
}
#[test]
fn line_debug_impl() {
let line = Line::raw("test");
let debug = format!("{:?}", line);
assert!(debug.contains("Line"));
}
#[test]
fn line_default_is_empty() {
let line = Line::default();
assert!(line.is_empty());
}
#[test]
fn text_style_returns_first_span_style() {
let style = Style::new().bold();
let text = Text::styled("hello", style);
assert_eq!(text.style(), Some(style));
}
#[test]
fn text_style_returns_none_for_empty() {
let text = Text::new();
assert!(text.style().is_none());
}
#[test]
fn text_style_returns_none_for_unstyled() {
let text = Text::raw("plain");
assert!(text.style().is_none());
}
#[test]
fn text_with_line_builder() {
let text = Text::raw("line 1").with_line(Line::raw("line 2"));
assert_eq!(text.height(), 2);
}
#[test]
fn text_with_span_builder() {
let text = Text::raw("hello ").with_span(Span::raw("world"));
assert_eq!(text.to_plain_text(), "hello world");
}
#[test]
fn text_with_base_style_builder() {
let text = Text::raw("test").with_base_style(Style::new().bold());
assert!(
text.lines()[0].spans()[0]
.style
.unwrap()
.has_attr(StyleFlags::BOLD)
);
}
#[test]
fn text_height_as_u16() {
let text = Text::raw("a\nb\nc");
assert_eq!(text.height_as_u16(), 3);
}
#[test]
fn text_height_as_u16_saturates() {
let text = Text::new();
assert_eq!(text.height_as_u16(), 0);
}
#[test]
fn text_measurement() {
let text = Text::raw("short\nlonger line");
let m = text.measurement();
assert_eq!(m.minimum, 11); assert_eq!(m.maximum, 11);
}
#[test]
fn text_from_segments_with_newlines() {
let segments = vec![
Segment::text("line 1"),
Segment::newline(),
Segment::text("line 2"),
];
let text = Text::from_segments(segments);
assert_eq!(text.height(), 2);
assert_eq!(text.lines()[0].to_plain_text(), "line 1");
assert_eq!(text.lines()[1].to_plain_text(), "line 2");
}
#[test]
fn text_converts_to_segment_lines_multiline() {
let text = Text::raw("a\nb");
let seg_lines = text.into_segment_lines();
assert_eq!(seg_lines.len(), 2);
}
#[test]
fn text_iter() {
let text = Text::from_lines([Line::raw("a"), Line::raw("b")]);
let collected: Vec<_> = text.iter().collect();
assert_eq!(collected.len(), 2);
}
#[test]
fn text_debug_impl() {
let text = Text::raw("test");
let debug = format!("{:?}", text);
assert!(debug.contains("Text"));
}
#[test]
fn text_default_is_empty() {
let text = Text::default();
assert!(text.is_empty());
}
#[test]
fn truncate_ellipsis_wider_than_max() {
let mut text = Text::raw("ab");
text.truncate(2, Some("...")); assert!(text.width() <= 2);
}
#[test]
fn truncate_exact_width_no_change() {
let mut text = Text::raw("hello");
text.truncate(5, Some("..."));
assert_eq!(text.to_plain_text(), "hello"); }
#[test]
fn truncate_multiple_spans() {
let text = Text::from_spans([
Span::raw("hello "),
Span::styled("world", Style::new().bold()),
]);
let truncated = text.truncated(8, None);
assert_eq!(truncated.to_plain_text(), "hello wo");
}
#[test]
fn truncate_preserves_link() {
let mut text =
Text::from_spans([Span::raw("click ").link("https://a.com"), Span::raw("here")]);
text.truncate(6, None);
assert!(text.lines()[0].spans()[0].link.is_some());
}
#[test]
fn push_span_on_empty_creates_line() {
let mut text = Text::new();
text.push_span(Span::raw("hello"));
assert_eq!(text.height(), 1);
assert_eq!(text.to_plain_text(), "hello");
}
#[test]
fn text_ref_into_iter() {
let text = Text::from_lines([Line::raw("a"), Line::raw("b")]);
let mut count = 0;
for _line in &text {
count += 1;
}
assert_eq!(count, 2);
}
#[test]
fn line_ref_into_iter() {
let line = Line::from_spans([Span::raw("a"), Span::raw("b")]);
let mut count = 0;
for _span in &line {
count += 1;
}
assert_eq!(count, 2);
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn raw_text_roundtrips(s in "[a-zA-Z0-9 \n]{0,100}") {
let text = Text::raw(&s);
let plain = text.to_plain_text();
prop_assert_eq!(plain, s);
}
#[test]
fn truncate_never_exceeds_width(s in "[a-zA-Z0-9]{1,50}", max_width in 1usize..20) {
let mut text = Text::raw(&s);
text.truncate(max_width, None);
prop_assert!(text.width() <= max_width);
}
#[test]
fn truncate_with_ellipsis_never_exceeds_width(s in "[a-zA-Z0-9]{1,50}", max_width in 4usize..20) {
let mut text = Text::raw(&s);
text.truncate(max_width, Some("..."));
prop_assert!(text.width() <= max_width);
}
#[test]
fn height_equals_newline_count_plus_one(s in "[a-zA-Z\n]{1,100}") {
let text = Text::raw(&s);
let newline_count = s.chars().filter(|&c| c == '\n').count();
prop_assert_eq!(text.height(), newline_count + 1);
}
#[test]
fn from_segments_preserves_content(
parts in prop::collection::vec("[a-z]{1,10}", 1..5)
) {
let segments: Vec<Segment> = parts.iter()
.map(|s| Segment::text(s.as_str()))
.collect();
let text = Text::from_segments(segments);
let plain = text.to_plain_text();
let expected: String = parts.join("");
prop_assert_eq!(plain, expected);
}
}
}