Skip to main content

ftui_text/
text.rs

1#![forbid(unsafe_code)]
2
3//! Text type for styled text collections.
4//!
5//! `Text` is a higher-level type that collects styled segments and provides
6//! ergonomic APIs for text manipulation, styling, and rendering.
7//!
8//! # Example
9//! ```
10//! use ftui_text::{Text, Span};
11//! use ftui_style::Style;
12//!
13//! // Simple construction
14//! let text = Text::raw("Hello, world!");
15//!
16//! // Styled text
17//! let styled = Text::styled("Error!", Style::new().bold());
18//!
19//! // Build from spans
20//! let text = Text::from_spans([
21//!     Span::raw("Normal "),
22//!     Span::styled("bold", Style::new().bold()),
23//!     Span::raw(" normal"),
24//! ]);
25//!
26//! // Chain spans with builder pattern
27//! let text = Text::raw("Status: ")
28//!     .with_span(Span::styled("OK", Style::new().bold()));
29//! ```
30
31use crate::TextMeasurement;
32use crate::grapheme_width;
33use crate::segment::{Segment, SegmentLine, SegmentLines, split_into_lines};
34use crate::wrap::{WrapMode, graphemes, truncate_to_width_with_info};
35use ftui_style::Style;
36use std::borrow::Cow;
37use unicode_segmentation::UnicodeSegmentation;
38
39/// A styled span of text.
40///
41/// Span is a simple wrapper around text and optional style, providing
42/// an ergonomic builder for creating styled text units.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Span<'a> {
45    /// The text content.
46    pub content: Cow<'a, str>,
47    /// Optional style for this span.
48    pub style: Option<Style>,
49    /// Optional hyperlink URL (OSC 8).
50    pub link: Option<Cow<'a, str>>,
51}
52
53impl<'a> Span<'a> {
54    /// Create an unstyled span.
55    #[inline]
56    #[must_use]
57    pub fn raw(content: impl Into<Cow<'a, str>>) -> Self {
58        Self {
59            content: content.into(),
60            style: None,
61            link: None,
62        }
63    }
64
65    /// Create a styled span.
66    #[inline]
67    #[must_use]
68    pub fn styled(content: impl Into<Cow<'a, str>>, style: Style) -> Self {
69        Self {
70            content: content.into(),
71            style: Some(style),
72            link: None,
73        }
74    }
75
76    /// Set the hyperlink URL for this span.
77    #[inline]
78    #[must_use]
79    pub fn link(mut self, link: impl Into<Cow<'a, str>>) -> Self {
80        self.link = Some(link.into());
81        self
82    }
83
84    /// Get the text content.
85    #[inline]
86    #[must_use]
87    pub fn as_str(&self) -> &str {
88        &self.content
89    }
90
91    /// Get the display width in cells.
92    #[inline]
93    #[must_use]
94    pub fn width(&self) -> usize {
95        crate::display_width(&self.content)
96    }
97
98    /// Split the span at a cell position.
99    ///
100    /// Returns `(left, right)` where the split respects grapheme boundaries.
101    #[must_use]
102    pub fn split_at_cell(&self, cell_pos: usize) -> (Self, Self) {
103        if self.content.is_empty() || cell_pos == 0 {
104            return (Self::raw(""), self.clone());
105        }
106
107        let total_width = self.width();
108        if cell_pos >= total_width {
109            return (self.clone(), Self::raw(""));
110        }
111
112        let (byte_pos, _actual_width) = find_cell_boundary(&self.content, cell_pos);
113        let (left, right) = self.content.split_at(byte_pos);
114
115        (
116            Self {
117                content: Cow::Owned(left.to_string()),
118                style: self.style,
119                link: self.link.clone(),
120            },
121            Self {
122                content: Cow::Owned(right.to_string()),
123                style: self.style,
124                link: self.link.clone(),
125            },
126        )
127    }
128
129    /// Return bounds-based measurement for this span.
130    #[must_use]
131    pub fn measurement(&self) -> TextMeasurement {
132        let width = self.width();
133        TextMeasurement {
134            minimum: width,
135            maximum: width,
136        }
137    }
138
139    /// Check if the span is empty.
140    #[inline]
141    #[must_use]
142    pub fn is_empty(&self) -> bool {
143        self.content.is_empty()
144    }
145
146    /// Apply a style to this span.
147    #[inline]
148    #[must_use]
149    pub fn with_style(mut self, style: Style) -> Self {
150        self.style = Some(style);
151        self
152    }
153
154    /// Convert to a segment.
155    #[inline]
156    #[must_use]
157    pub fn into_segment(self) -> Segment<'a> {
158        // Segments don't support links yet, so we ignore it.
159        // TODO: Add link support to Segment if needed for lower-level handling.
160        match self.style {
161            Some(style) => Segment::styled(self.content, style),
162            None => Segment::text(self.content),
163        }
164    }
165
166    /// Convert to an owned span.
167    #[must_use]
168    pub fn into_owned(self) -> Span<'static> {
169        Span {
170            content: Cow::Owned(self.content.into_owned()),
171            style: self.style,
172            link: self.link.map(|l| Cow::Owned(l.into_owned())),
173        }
174    }
175}
176
177impl<'a> From<&'a str> for Span<'a> {
178    fn from(s: &'a str) -> Self {
179        Self::raw(s)
180    }
181}
182
183impl From<String> for Span<'static> {
184    fn from(s: String) -> Self {
185        Self::raw(s)
186    }
187}
188
189impl<'a> From<Segment<'a>> for Span<'a> {
190    fn from(seg: Segment<'a>) -> Self {
191        Self {
192            content: seg.text,
193            style: seg.style,
194            link: None,
195        }
196    }
197}
198
199impl Default for Span<'_> {
200    fn default() -> Self {
201        Self::raw("")
202    }
203}
204
205/// A collection of styled text spans.
206///
207/// `Text` provides a high-level interface for working with styled text.
208/// It stores spans (styled text units) and provides operations for:
209/// - Appending and building text
210/// - Applying base styles
211/// - Splitting into lines
212/// - Truncating and wrapping to widths
213///
214/// # Ownership
215/// `Text` uses `Cow<'static, str>` for storage, which means:
216/// - String literals are stored by reference (zero-copy)
217/// - Owned strings are stored inline
218/// - The API is ergonomic (no lifetime parameters on Text)
219#[derive(Debug, Clone, Default, PartialEq, Eq)]
220pub struct Text {
221    /// The lines of styled spans.
222    lines: Vec<Line>,
223}
224
225/// A single line of styled spans.
226#[derive(Debug, Clone, Default, PartialEq, Eq)]
227pub struct Line {
228    spans: Vec<Span<'static>>,
229}
230
231impl Line {
232    /// Create an empty line.
233    #[inline]
234    #[must_use]
235    pub const fn new() -> Self {
236        Self { spans: Vec::new() }
237    }
238
239    /// Create a line from spans.
240    #[must_use]
241    pub fn from_spans<'a>(spans: impl IntoIterator<Item = Span<'a>>) -> Self {
242        Self {
243            spans: spans.into_iter().map(|s| s.into_owned()).collect(),
244        }
245    }
246
247    /// Create a line from a single raw string.
248    #[inline]
249    #[must_use]
250    pub fn raw(content: impl Into<String>) -> Self {
251        Self {
252            spans: vec![Span::raw(content.into())],
253        }
254    }
255
256    /// Create a line from a single styled string.
257    #[inline]
258    #[must_use]
259    pub fn styled(content: impl Into<String>, style: Style) -> Self {
260        Self {
261            spans: vec![Span::styled(content.into(), style)],
262        }
263    }
264
265    /// Check if the line is empty.
266    #[inline]
267    #[must_use]
268    pub fn is_empty(&self) -> bool {
269        self.spans.is_empty() || self.spans.iter().all(|s| s.is_empty())
270    }
271
272    /// Get the number of spans.
273    #[inline]
274    #[must_use]
275    pub fn len(&self) -> usize {
276        self.spans.len()
277    }
278
279    /// Get the display width in cells.
280    #[inline]
281    #[must_use]
282    pub fn width(&self) -> usize {
283        self.spans.iter().map(|s| s.width()).sum()
284    }
285
286    /// Return bounds-based measurement for this line.
287    #[must_use]
288    pub fn measurement(&self) -> TextMeasurement {
289        let width = self.width();
290        TextMeasurement {
291            minimum: width,
292            maximum: width,
293        }
294    }
295
296    /// Get the spans.
297    #[inline]
298    #[must_use]
299    pub fn spans(&self) -> &[Span<'static>] {
300        &self.spans
301    }
302
303    /// Add a span to the line.
304    #[inline]
305    pub fn push_span<'a>(&mut self, span: Span<'a>) {
306        self.spans.push(span.into_owned());
307    }
308
309    /// Append a span (builder pattern).
310    #[inline]
311    #[must_use]
312    pub fn with_span<'a>(mut self, span: Span<'a>) -> Self {
313        self.push_span(span);
314        self
315    }
316
317    /// Apply a base style to all spans.
318    ///
319    /// The base style is merged with each span's style, with the span's
320    /// style taking precedence for conflicting properties.
321    pub fn apply_base_style(&mut self, base: Style) {
322        for span in &mut self.spans {
323            span.style = Some(match span.style {
324                Some(existing) => existing.merge(&base),
325                None => base,
326            });
327        }
328    }
329
330    /// Get the plain text content.
331    #[must_use]
332    pub fn to_plain_text(&self) -> String {
333        self.spans.iter().map(|s| s.as_str()).collect()
334    }
335
336    /// Wrap this line to the given width, preserving span styles.
337    #[must_use]
338    pub fn wrap(&self, width: usize, mode: WrapMode) -> Vec<Line> {
339        if mode == WrapMode::None || width == 0 {
340            return vec![self.clone()];
341        }
342
343        if self.is_empty() {
344            return vec![Line::new()];
345        }
346
347        match mode {
348            WrapMode::None => vec![self.clone()],
349            WrapMode::Char => wrap_line_chars(self, width),
350            WrapMode::Word => wrap_line_words(self, width, false),
351            WrapMode::WordChar => wrap_line_words(self, width, true),
352        }
353    }
354
355    /// Convert to segments.
356    #[must_use]
357    pub fn into_segments(self) -> Vec<Segment<'static>> {
358        self.spans.into_iter().map(|s| s.into_segment()).collect()
359    }
360
361    /// Convert to a SegmentLine.
362    #[must_use]
363    pub fn into_segment_line(self) -> SegmentLine<'static> {
364        SegmentLine::from_segments(self.into_segments())
365    }
366
367    /// Iterate over spans.
368    pub fn iter(&self) -> impl Iterator<Item = &Span<'static>> {
369        self.spans.iter()
370    }
371}
372
373impl<'a> From<Span<'a>> for Line {
374    fn from(span: Span<'a>) -> Self {
375        Self {
376            spans: vec![span.into_owned()],
377        }
378    }
379}
380
381impl From<&str> for Line {
382    fn from(s: &str) -> Self {
383        Self::raw(s)
384    }
385}
386
387impl From<String> for Line {
388    fn from(s: String) -> Self {
389        Self::raw(s)
390    }
391}
392
393impl IntoIterator for Line {
394    type Item = Span<'static>;
395    type IntoIter = std::vec::IntoIter<Span<'static>>;
396
397    fn into_iter(self) -> Self::IntoIter {
398        self.spans.into_iter()
399    }
400}
401
402impl<'a> IntoIterator for &'a Line {
403    type Item = &'a Span<'static>;
404    type IntoIter = std::slice::Iter<'a, Span<'static>>;
405
406    fn into_iter(self) -> Self::IntoIter {
407        self.spans.iter()
408    }
409}
410
411impl Text {
412    /// Create an empty text.
413    #[inline]
414    #[must_use]
415    pub const fn new() -> Self {
416        Self { lines: Vec::new() }
417    }
418
419    /// Create text from a raw string (may contain newlines).
420    #[must_use]
421    pub fn raw(content: impl AsRef<str>) -> Self {
422        let content = content.as_ref();
423        if content.is_empty() {
424            return Self::new();
425        }
426
427        let lines: Vec<Line> = content.split('\n').map(Line::raw).collect();
428
429        Self { lines }
430    }
431
432    /// Create styled text from a string (may contain newlines).
433    #[must_use]
434    pub fn styled(content: impl AsRef<str>, style: Style) -> Self {
435        let content = content.as_ref();
436        if content.is_empty() {
437            return Self::new();
438        }
439
440        let lines: Vec<Line> = content
441            .split('\n')
442            .map(|s| Line::styled(s, style))
443            .collect();
444
445        Self { lines }
446    }
447
448    /// Create text from a single line.
449    #[inline]
450    #[must_use]
451    pub fn from_line(line: Line) -> Self {
452        Self { lines: vec![line] }
453    }
454
455    /// Create text from multiple lines.
456    #[must_use]
457    pub fn from_lines(lines: impl IntoIterator<Item = Line>) -> Self {
458        Self {
459            lines: lines.into_iter().collect(),
460        }
461    }
462
463    /// Create text from spans (single line).
464    #[must_use]
465    pub fn from_spans<'a>(spans: impl IntoIterator<Item = Span<'a>>) -> Self {
466        Self {
467            lines: vec![Line::from_spans(spans)],
468        }
469    }
470
471    /// Create text from segments.
472    #[must_use]
473    pub fn from_segments<'a>(segments: impl IntoIterator<Item = Segment<'a>>) -> Self {
474        let segment_lines = split_into_lines(segments);
475        let lines: Vec<Line> = segment_lines
476            .into_iter()
477            .map(|seg_line| Line::from_spans(seg_line.into_iter().map(Span::from)))
478            .collect();
479
480        Self { lines }
481    }
482
483    /// Check if empty.
484    #[inline]
485    #[must_use]
486    pub fn is_empty(&self) -> bool {
487        self.lines.is_empty() || self.lines.iter().all(|l| l.is_empty())
488    }
489
490    /// Get the number of lines.
491    #[inline]
492    #[must_use]
493    pub fn height(&self) -> usize {
494        self.lines.len()
495    }
496
497    /// Get the number of lines as u16, saturating at u16::MAX.
498    #[inline]
499    #[must_use]
500    pub fn height_as_u16(&self) -> u16 {
501        self.lines.len().try_into().unwrap_or(u16::MAX)
502    }
503
504    /// Get the maximum width across all lines.
505    #[inline]
506    #[must_use]
507    pub fn width(&self) -> usize {
508        self.lines.iter().map(|l| l.width()).max().unwrap_or(0)
509    }
510
511    /// Return bounds-based measurement for this text block.
512    #[must_use]
513    pub fn measurement(&self) -> TextMeasurement {
514        let width = self.width();
515        TextMeasurement {
516            minimum: width,
517            maximum: width,
518        }
519    }
520
521    /// Get the lines.
522    #[inline]
523    #[must_use]
524    pub fn lines(&self) -> &[Line] {
525        &self.lines
526    }
527
528    /// Get the style of the first span, if any.
529    ///
530    /// Returns `None` if the text is empty or has no styled spans.
531    #[inline]
532    #[must_use]
533    pub fn style(&self) -> Option<Style> {
534        self.lines
535            .first()
536            .and_then(|line| line.spans().first())
537            .and_then(|span| span.style)
538    }
539
540    /// Add a line.
541    #[inline]
542    pub fn push_line(&mut self, line: Line) {
543        self.lines.push(line);
544    }
545
546    /// Append a line (builder pattern).
547    #[inline]
548    #[must_use]
549    pub fn with_line(mut self, line: Line) -> Self {
550        self.push_line(line);
551        self
552    }
553
554    /// Add a span to the last line (or create new line if empty).
555    pub fn push_span<'a>(&mut self, span: Span<'a>) {
556        if self.lines.is_empty() {
557            self.lines.push(Line::new());
558        }
559        if let Some(last) = self.lines.last_mut() {
560            last.push_span(span);
561        }
562    }
563
564    /// Append a span to the last line (builder pattern).
565    #[must_use]
566    pub fn with_span<'a>(mut self, span: Span<'a>) -> Self {
567        self.push_span(span);
568        self
569    }
570
571    /// Apply a base style to all lines and spans.
572    ///
573    /// The base style is merged with each span's style, with the span's
574    /// style taking precedence for conflicting properties.
575    pub fn apply_base_style(&mut self, base: Style) {
576        for line in &mut self.lines {
577            line.apply_base_style(base);
578        }
579    }
580
581    /// Create a new Text with base style applied.
582    #[must_use]
583    pub fn with_base_style(mut self, base: Style) -> Self {
584        self.apply_base_style(base);
585        self
586    }
587
588    /// Get the plain text content (lines joined with newlines).
589    #[must_use]
590    pub fn to_plain_text(&self) -> String {
591        self.lines
592            .iter()
593            .map(|l| l.to_plain_text())
594            .collect::<Vec<_>>()
595            .join("\n")
596    }
597
598    /// Convert to SegmentLines.
599    #[must_use]
600    pub fn into_segment_lines(self) -> SegmentLines<'static> {
601        SegmentLines::from_lines(
602            self.lines
603                .into_iter()
604                .map(|l| l.into_segment_line())
605                .collect(),
606        )
607    }
608
609    /// Iterate over lines.
610    pub fn iter(&self) -> impl Iterator<Item = &Line> {
611        self.lines.iter()
612    }
613
614    /// Truncate all lines to a maximum width.
615    ///
616    /// Lines exceeding `max_width` are truncated. If `ellipsis` is provided,
617    /// it replaces the end of truncated lines.
618    pub fn truncate(&mut self, max_width: usize, ellipsis: Option<&str>) {
619        let ellipsis_width = ellipsis.map(crate::display_width).unwrap_or(0);
620
621        for line in &mut self.lines {
622            let line_width = line.width();
623            if line_width <= max_width {
624                continue;
625            }
626
627            // Calculate how much content we can keep
628            let (content_width, use_ellipsis) = if ellipsis.is_some() && max_width >= ellipsis_width
629            {
630                (max_width - ellipsis_width, true)
631            } else {
632                (max_width, false)
633            };
634
635            // Truncate spans
636            let mut remaining = content_width;
637            let mut new_spans = Vec::new();
638
639            for span in &line.spans {
640                if remaining == 0 {
641                    break;
642                }
643
644                let span_width = span.width();
645                if span_width <= remaining {
646                    new_spans.push(span.clone());
647                    remaining -= span_width;
648                } else {
649                    // Need to truncate this span
650                    let (truncated, _) = truncate_to_width_with_info(&span.content, remaining);
651                    if !truncated.is_empty() {
652                        new_spans.push(Span {
653                            content: Cow::Owned(truncated.to_string()),
654                            style: span.style,
655                            link: span.link.clone(),
656                        });
657                    }
658                    remaining = 0;
659                }
660            }
661
662            // Add ellipsis if needed and we have space
663            if use_ellipsis
664                && line_width > max_width
665                && let Some(e) = ellipsis
666            {
667                new_spans.push(Span::raw(e.to_string()));
668            }
669
670            line.spans = new_spans;
671        }
672    }
673
674    /// Create a truncated copy.
675    #[must_use]
676    pub fn truncated(&self, max_width: usize, ellipsis: Option<&str>) -> Self {
677        let mut text = self.clone();
678        text.truncate(max_width, ellipsis);
679        text
680    }
681}
682
683// ---------------------------------------------------------------------------
684// Wrap Helpers (style-preserving)
685// ---------------------------------------------------------------------------
686
687fn find_cell_boundary(text: &str, target_cells: usize) -> (usize, usize) {
688    let mut current_cells = 0;
689    let mut byte_pos = 0;
690
691    for grapheme in graphemes(text) {
692        let grapheme_width = grapheme_width(grapheme);
693
694        if current_cells + grapheme_width > target_cells {
695            break;
696        }
697
698        current_cells += grapheme_width;
699        byte_pos += grapheme.len();
700
701        if current_cells >= target_cells {
702            break;
703        }
704    }
705
706    (byte_pos, current_cells)
707}
708
709fn span_is_whitespace(span: &Span<'static>) -> bool {
710    span.as_str()
711        .graphemes(true)
712        .all(|g| g.chars().all(|c| c.is_whitespace()))
713}
714
715fn trim_span_start(span: Span<'static>) -> Span<'static> {
716    let text = span.as_str();
717    let mut start = 0;
718    let mut found = false;
719
720    for (idx, grapheme) in text.grapheme_indices(true) {
721        if grapheme.chars().all(|c| c.is_whitespace()) {
722            start = idx + grapheme.len();
723            continue;
724        }
725        found = true;
726        break;
727    }
728
729    if !found {
730        return Span::raw("");
731    }
732
733    Span {
734        content: Cow::Owned(text[start..].to_string()),
735        style: span.style,
736        link: span.link,
737    }
738}
739
740fn trim_span_end(span: Span<'static>) -> Span<'static> {
741    let text = span.as_str();
742    let mut end = text.len();
743    let mut found = false;
744
745    for (idx, grapheme) in text.grapheme_indices(true).rev() {
746        if grapheme.chars().all(|c| c.is_whitespace()) {
747            end = idx;
748            continue;
749        }
750        found = true;
751        break;
752    }
753
754    if !found {
755        return Span::raw("");
756    }
757
758    Span {
759        content: Cow::Owned(text[..end].to_string()),
760        style: span.style,
761        link: span.link,
762    }
763}
764
765fn trim_line_trailing(mut line: Line) -> Line {
766    while let Some(last) = line.spans.last().cloned() {
767        let trimmed = trim_span_end(last);
768        if trimmed.is_empty() {
769            line.spans.pop();
770            continue;
771        }
772        let len = line.spans.len();
773        if len > 0 {
774            line.spans[len - 1] = trimmed;
775        }
776        break;
777    }
778    line
779}
780
781fn push_span_merged(line: &mut Line, span: Span<'static>) {
782    if span.is_empty() {
783        return;
784    }
785
786    if let Some(last) = line.spans.last_mut()
787        && last.style == span.style
788        && last.link == span.link
789    {
790        let mut merged = String::with_capacity(last.as_str().len() + span.as_str().len());
791        merged.push_str(last.as_str());
792        merged.push_str(span.as_str());
793        last.content = Cow::Owned(merged);
794        return;
795    }
796
797    line.spans.push(span);
798}
799
800fn split_span_words(span: &Span<'static>) -> Vec<Span<'static>> {
801    let mut segments = Vec::new();
802    let mut current = String::new();
803    let mut in_whitespace = false;
804
805    for grapheme in span.as_str().graphemes(true) {
806        let is_ws = grapheme.chars().all(|c| c.is_whitespace());
807
808        if is_ws != in_whitespace && !current.is_empty() {
809            segments.push(Span {
810                content: Cow::Owned(std::mem::take(&mut current)),
811                style: span.style,
812                link: span.link.clone(),
813            });
814        }
815
816        current.push_str(grapheme);
817        in_whitespace = is_ws;
818    }
819
820    if !current.is_empty() {
821        segments.push(Span {
822            content: Cow::Owned(current),
823            style: span.style,
824            link: span.link.clone(),
825        });
826    }
827
828    segments
829}
830
831fn wrap_line_chars(line: &Line, width: usize) -> Vec<Line> {
832    let mut lines = Vec::new();
833    let mut current = Line::new();
834    let mut current_width = 0;
835
836    for span in line.spans.iter().cloned() {
837        let mut remaining = span;
838        while !remaining.is_empty() {
839            if current_width >= width && !current.is_empty() {
840                lines.push(trim_line_trailing(current));
841                current = Line::new();
842                current_width = 0;
843            }
844
845            let available = width.saturating_sub(current_width).max(1);
846            let span_width = remaining.width();
847
848            if span_width <= available {
849                current_width += span_width;
850                push_span_merged(&mut current, remaining);
851                break;
852            }
853
854            let (left, right) = remaining.split_at_cell(available);
855
856            // Force progress if the first grapheme is too wide for `available`
857            // and we are at the start of a line (so we can't wrap further).
858            let (left, right) = if left.is_empty() && current.is_empty() && !remaining.is_empty() {
859                let first_w = remaining
860                    .as_str()
861                    .graphemes(true)
862                    .next()
863                    .map(grapheme_width)
864                    .unwrap_or(1);
865                remaining.split_at_cell(first_w.max(1))
866            } else {
867                (left, right)
868            };
869
870            if !left.is_empty() {
871                push_span_merged(&mut current, left);
872            }
873            lines.push(trim_line_trailing(current));
874            current = Line::new();
875            current_width = 0;
876            remaining = right;
877        }
878    }
879
880    if !current.is_empty() || lines.is_empty() {
881        lines.push(trim_line_trailing(current));
882    }
883
884    lines
885}
886
887fn wrap_line_words(line: &Line, width: usize, char_fallback: bool) -> Vec<Line> {
888    let mut pieces = Vec::new();
889    for span in &line.spans {
890        pieces.extend(split_span_words(span));
891    }
892
893    let mut lines = Vec::new();
894    let mut current = Line::new();
895    let mut current_width = 0;
896    let mut first_line = true;
897
898    for piece in pieces {
899        let piece_width = piece.width();
900        let is_ws = span_is_whitespace(&piece);
901
902        if current_width + piece_width <= width {
903            if current_width == 0 && !first_line && is_ws {
904                continue;
905            }
906            current_width += piece_width;
907            push_span_merged(&mut current, piece);
908            continue;
909        }
910
911        if !current.is_empty() {
912            lines.push(trim_line_trailing(current));
913            current = Line::new();
914            current_width = 0;
915            first_line = false;
916        }
917
918        if piece_width > width {
919            if char_fallback {
920                let mut remaining = piece;
921                while !remaining.is_empty() {
922                    if current_width >= width && !current.is_empty() {
923                        lines.push(trim_line_trailing(current));
924                        current = Line::new();
925                        current_width = 0;
926                        first_line = false;
927                    }
928
929                    let available = width.saturating_sub(current_width).max(1);
930                    let (left, right) = remaining.split_at_cell(available);
931
932                    // Force progress if the first grapheme is too wide for `available`
933                    // and we are at the start of a line (so we can't wrap further).
934                    let (left, right) =
935                        if left.is_empty() && current.is_empty() && !remaining.is_empty() {
936                            let first_w = remaining
937                                .as_str()
938                                .graphemes(true)
939                                .next()
940                                .map(grapheme_width)
941                                .unwrap_or(1);
942                            remaining.split_at_cell(first_w.max(1))
943                        } else {
944                            (left, right)
945                        };
946
947                    let mut left = left;
948
949                    if current_width == 0 && !first_line {
950                        left = trim_span_start(left);
951                    }
952
953                    if !left.is_empty() {
954                        current_width += left.width();
955                        push_span_merged(&mut current, left);
956                    }
957
958                    if current_width >= width && !current.is_empty() {
959                        lines.push(trim_line_trailing(current));
960                        current = Line::new();
961                        current_width = 0;
962                        first_line = false;
963                    }
964
965                    remaining = right;
966                }
967            } else if !is_ws {
968                let mut trimmed = piece;
969                if !first_line {
970                    trimmed = trim_span_start(trimmed);
971                }
972                if !trimmed.is_empty() {
973                    push_span_merged(&mut current, trimmed);
974                }
975                lines.push(trim_line_trailing(current));
976                current = Line::new();
977                current_width = 0;
978                first_line = false;
979            }
980            continue;
981        }
982
983        let mut trimmed = piece;
984        if !first_line {
985            trimmed = trim_span_start(trimmed);
986        }
987        if !trimmed.is_empty() {
988            current_width += trimmed.width();
989            push_span_merged(&mut current, trimmed);
990        }
991    }
992
993    if !current.is_empty() || lines.is_empty() {
994        lines.push(trim_line_trailing(current));
995    }
996
997    lines
998}
999
1000impl From<&str> for Text {
1001    fn from(s: &str) -> Self {
1002        Self::raw(s)
1003    }
1004}
1005
1006impl From<String> for Text {
1007    fn from(s: String) -> Self {
1008        Self::raw(s)
1009    }
1010}
1011
1012impl From<Line> for Text {
1013    fn from(line: Line) -> Self {
1014        Self::from_line(line)
1015    }
1016}
1017
1018impl<'a> FromIterator<Span<'a>> for Text {
1019    fn from_iter<I: IntoIterator<Item = Span<'a>>>(iter: I) -> Self {
1020        Self::from_spans(iter)
1021    }
1022}
1023
1024impl FromIterator<Line> for Text {
1025    fn from_iter<I: IntoIterator<Item = Line>>(iter: I) -> Self {
1026        Self::from_lines(iter)
1027    }
1028}
1029
1030impl IntoIterator for Text {
1031    type Item = Line;
1032    type IntoIter = std::vec::IntoIter<Line>;
1033
1034    fn into_iter(self) -> Self::IntoIter {
1035        self.lines.into_iter()
1036    }
1037}
1038
1039impl<'a> IntoIterator for &'a Text {
1040    type Item = &'a Line;
1041    type IntoIter = std::slice::Iter<'a, Line>;
1042
1043    fn into_iter(self) -> Self::IntoIter {
1044        self.lines.iter()
1045    }
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050    use super::*;
1051    use ftui_style::StyleFlags;
1052
1053    // ==========================================================================
1054    // Span tests
1055    // ==========================================================================
1056
1057    #[test]
1058    fn span_raw_creates_unstyled() {
1059        let span = Span::raw("hello");
1060        assert_eq!(span.as_str(), "hello");
1061        assert!(span.style.is_none());
1062    }
1063
1064    #[test]
1065    fn span_styled_creates_styled() {
1066        let style = Style::new().bold();
1067        let span = Span::styled("hello", style);
1068        assert_eq!(span.as_str(), "hello");
1069        assert_eq!(span.style, Some(style));
1070    }
1071
1072    #[test]
1073    fn span_width_ascii() {
1074        let span = Span::raw("hello");
1075        assert_eq!(span.width(), 5);
1076    }
1077
1078    #[test]
1079    fn span_width_cjk() {
1080        let span = Span::raw("你好");
1081        assert_eq!(span.width(), 4);
1082    }
1083
1084    #[test]
1085    fn span_into_segment() {
1086        let style = Style::new().bold();
1087        let span = Span::styled("hello", style);
1088        let seg = span.into_segment();
1089        assert_eq!(seg.as_str(), "hello");
1090        assert_eq!(seg.style, Some(style));
1091    }
1092
1093    // ==========================================================================
1094    // Line tests
1095    // ==========================================================================
1096
1097    #[test]
1098    fn line_empty() {
1099        let line = Line::new();
1100        assert!(line.is_empty());
1101        assert_eq!(line.width(), 0);
1102    }
1103
1104    #[test]
1105    fn line_raw() {
1106        let line = Line::raw("hello world");
1107        assert_eq!(line.width(), 11);
1108        assert_eq!(line.to_plain_text(), "hello world");
1109    }
1110
1111    #[test]
1112    fn line_styled() {
1113        let style = Style::new().bold();
1114        let line = Line::styled("hello", style);
1115        assert_eq!(line.spans()[0].style, Some(style));
1116    }
1117
1118    #[test]
1119    fn line_from_spans() {
1120        let line = Line::from_spans([Span::raw("hello "), Span::raw("world")]);
1121        assert_eq!(line.len(), 2);
1122        assert_eq!(line.width(), 11);
1123        assert_eq!(line.to_plain_text(), "hello world");
1124    }
1125
1126    #[test]
1127    fn line_push_span() {
1128        let mut line = Line::raw("hello ");
1129        line.push_span(Span::raw("world"));
1130        assert_eq!(line.len(), 2);
1131        assert_eq!(line.to_plain_text(), "hello world");
1132    }
1133
1134    #[test]
1135    fn line_apply_base_style() {
1136        let base = Style::new().bold();
1137        let mut line = Line::from_spans([
1138            Span::raw("hello"),
1139            Span::styled("world", Style::new().italic()),
1140        ]);
1141
1142        line.apply_base_style(base);
1143
1144        // First span should have bold
1145        assert!(line.spans()[0].style.unwrap().has_attr(StyleFlags::BOLD));
1146
1147        // Second span should have both bold and italic
1148        let second_style = line.spans()[1].style.unwrap();
1149        assert!(second_style.has_attr(StyleFlags::BOLD));
1150        assert!(second_style.has_attr(StyleFlags::ITALIC));
1151    }
1152
1153    #[test]
1154    fn line_wrap_preserves_styles_word() {
1155        let bold = Style::new().bold();
1156        let italic = Style::new().italic();
1157        let line = Line::from_spans([Span::styled("Hello", bold), Span::styled(" world", italic)]);
1158
1159        let wrapped = line.wrap(6, WrapMode::Word);
1160        assert_eq!(wrapped.len(), 2);
1161        assert_eq!(wrapped[0].spans()[0].as_str(), "Hello");
1162        assert_eq!(wrapped[0].spans()[0].style, Some(bold));
1163        assert_eq!(wrapped[1].spans()[0].as_str(), "world");
1164        assert_eq!(wrapped[1].spans()[0].style, Some(italic));
1165    }
1166
1167    // ==========================================================================
1168    // Text tests
1169    // ==========================================================================
1170
1171    #[test]
1172    fn text_empty() {
1173        let text = Text::new();
1174        assert!(text.is_empty());
1175        assert_eq!(text.height(), 0);
1176        assert_eq!(text.width(), 0);
1177    }
1178
1179    #[test]
1180    fn text_raw_single_line() {
1181        let text = Text::raw("hello world");
1182        assert_eq!(text.height(), 1);
1183        assert_eq!(text.width(), 11);
1184        assert_eq!(text.to_plain_text(), "hello world");
1185    }
1186
1187    #[test]
1188    fn text_raw_multiline() {
1189        let text = Text::raw("line 1\nline 2\nline 3");
1190        assert_eq!(text.height(), 3);
1191        assert_eq!(text.to_plain_text(), "line 1\nline 2\nline 3");
1192    }
1193
1194    #[test]
1195    fn text_styled() {
1196        let style = Style::new().bold();
1197        let text = Text::styled("hello", style);
1198        assert_eq!(text.lines()[0].spans()[0].style, Some(style));
1199    }
1200
1201    #[test]
1202    fn text_from_spans() {
1203        let text = Text::from_spans([Span::raw("hello "), Span::raw("world")]);
1204        assert_eq!(text.height(), 1);
1205        assert_eq!(text.to_plain_text(), "hello world");
1206    }
1207
1208    #[test]
1209    fn text_from_lines() {
1210        let text = Text::from_lines([Line::raw("line 1"), Line::raw("line 2")]);
1211        assert_eq!(text.height(), 2);
1212        assert_eq!(text.to_plain_text(), "line 1\nline 2");
1213    }
1214
1215    #[test]
1216    fn text_push_line() {
1217        let mut text = Text::raw("line 1");
1218        text.push_line(Line::raw("line 2"));
1219        assert_eq!(text.height(), 2);
1220    }
1221
1222    #[test]
1223    fn text_push_span() {
1224        let mut text = Text::raw("hello ");
1225        text.push_span(Span::raw("world"));
1226        assert_eq!(text.to_plain_text(), "hello world");
1227    }
1228
1229    #[test]
1230    fn text_apply_base_style() {
1231        let base = Style::new().bold();
1232        let mut text = Text::from_lines([
1233            Line::raw("line 1"),
1234            Line::styled("line 2", Style::new().italic()),
1235        ]);
1236
1237        text.apply_base_style(base);
1238
1239        // First line should have bold
1240        assert!(
1241            text.lines()[0].spans()[0]
1242                .style
1243                .unwrap()
1244                .has_attr(StyleFlags::BOLD)
1245        );
1246
1247        // Second line should have both
1248        let second_style = text.lines()[1].spans()[0].style.unwrap();
1249        assert!(second_style.has_attr(StyleFlags::BOLD));
1250        assert!(second_style.has_attr(StyleFlags::ITALIC));
1251    }
1252
1253    #[test]
1254    fn text_width_multiline() {
1255        let text = Text::raw("short\nlonger line\nmed");
1256        assert_eq!(text.width(), 11); // "longer line" is widest
1257    }
1258
1259    // ==========================================================================
1260    // Truncation tests
1261    // ==========================================================================
1262
1263    #[test]
1264    fn truncate_no_change_if_fits() {
1265        let mut text = Text::raw("hello");
1266        text.truncate(10, None);
1267        assert_eq!(text.to_plain_text(), "hello");
1268    }
1269
1270    #[test]
1271    fn truncate_simple() {
1272        let mut text = Text::raw("hello world");
1273        text.truncate(5, None);
1274        assert_eq!(text.to_plain_text(), "hello");
1275    }
1276
1277    #[test]
1278    fn truncate_with_ellipsis() {
1279        let mut text = Text::raw("hello world");
1280        text.truncate(8, Some("..."));
1281        assert_eq!(text.to_plain_text(), "hello...");
1282    }
1283
1284    #[test]
1285    fn truncate_multiline() {
1286        let mut text = Text::raw("hello world\nfoo bar baz");
1287        text.truncate(8, Some("..."));
1288        assert_eq!(text.to_plain_text(), "hello...\nfoo b...");
1289    }
1290
1291    #[test]
1292    fn truncate_preserves_style() {
1293        let style = Style::new().bold();
1294        let mut text = Text::styled("hello world", style);
1295        text.truncate(5, None);
1296
1297        assert_eq!(text.lines()[0].spans()[0].style, Some(style));
1298    }
1299
1300    #[test]
1301    fn truncate_cjk() {
1302        let mut text = Text::raw("你好世界"); // 8 cells
1303        text.truncate(4, None);
1304        assert_eq!(text.to_plain_text(), "你好");
1305    }
1306
1307    #[test]
1308    fn truncate_cjk_odd_width() {
1309        let mut text = Text::raw("你好世界"); // 8 cells
1310        text.truncate(5, None); // Can't fit half a char, so only 4
1311        assert_eq!(text.to_plain_text(), "你好");
1312    }
1313
1314    // ==========================================================================
1315    // Conversion tests
1316    // ==========================================================================
1317
1318    #[test]
1319    fn text_from_str() {
1320        let text: Text = "hello".into();
1321        assert_eq!(text.to_plain_text(), "hello");
1322    }
1323
1324    #[test]
1325    fn text_from_string() {
1326        let text: Text = String::from("hello").into();
1327        assert_eq!(text.to_plain_text(), "hello");
1328    }
1329
1330    #[test]
1331    fn text_from_empty_string_is_empty() {
1332        let text: Text = String::new().into();
1333        assert!(text.is_empty());
1334        assert_eq!(text.height(), 0);
1335        assert_eq!(text.width(), 0);
1336    }
1337
1338    #[test]
1339    fn text_from_empty_line_preserves_single_empty_line() {
1340        let text: Text = Line::new().into();
1341        assert_eq!(text.height(), 1);
1342        assert!(text.lines()[0].is_empty());
1343        assert_eq!(text.width(), 0);
1344    }
1345
1346    #[test]
1347    fn text_from_lines_empty_iter_is_empty() {
1348        let text = Text::from_lines(Vec::<Line>::new());
1349        assert!(text.is_empty());
1350        assert_eq!(text.height(), 0);
1351    }
1352
1353    #[test]
1354    fn text_from_str_preserves_empty_middle_line() {
1355        let text: Text = "a\n\nb".into();
1356        assert_eq!(text.height(), 3);
1357        assert_eq!(text.lines()[0].to_plain_text(), "a");
1358        assert!(text.lines()[1].is_empty());
1359        assert_eq!(text.lines()[2].to_plain_text(), "b");
1360        assert_eq!(text.to_plain_text(), "a\n\nb");
1361    }
1362
1363    #[test]
1364    fn text_into_segment_lines() {
1365        let text = Text::raw("line 1\nline 2");
1366        let seg_lines = text.into_segment_lines();
1367        assert_eq!(seg_lines.len(), 2);
1368    }
1369
1370    #[test]
1371    fn line_into_iter() {
1372        let line = Line::from_spans([Span::raw("a"), Span::raw("b")]);
1373        let collected: Vec<_> = line.into_iter().collect();
1374        assert_eq!(collected.len(), 2);
1375    }
1376
1377    #[test]
1378    fn text_into_iter() {
1379        let text = Text::from_lines([Line::raw("a"), Line::raw("b")]);
1380        let collected: Vec<_> = text.into_iter().collect();
1381        assert_eq!(collected.len(), 2);
1382    }
1383
1384    #[test]
1385    fn text_collect_from_spans() {
1386        let text: Text = [Span::raw("a"), Span::raw("b")].into_iter().collect();
1387        assert_eq!(text.height(), 1);
1388        assert_eq!(text.to_plain_text(), "ab");
1389    }
1390
1391    #[test]
1392    fn text_collect_from_lines() {
1393        let text: Text = [Line::raw("a"), Line::raw("b")].into_iter().collect();
1394        assert_eq!(text.height(), 2);
1395    }
1396
1397    // ==========================================================================
1398    // Edge cases
1399    // ==========================================================================
1400
1401    #[test]
1402    fn empty_string_creates_empty_text() {
1403        let text = Text::raw("");
1404        assert!(text.is_empty());
1405    }
1406
1407    #[test]
1408    fn single_newline_creates_two_empty_lines() {
1409        let text = Text::raw("\n");
1410        assert_eq!(text.height(), 2);
1411        assert!(text.lines()[0].is_empty());
1412        assert!(text.lines()[1].is_empty());
1413    }
1414
1415    #[test]
1416    fn trailing_newline() {
1417        let text = Text::raw("hello\n");
1418        assert_eq!(text.height(), 2);
1419        assert_eq!(text.lines()[0].to_plain_text(), "hello");
1420        assert!(text.lines()[1].is_empty());
1421    }
1422
1423    #[test]
1424    fn leading_newline() {
1425        let text = Text::raw("\nhello");
1426        assert_eq!(text.height(), 2);
1427        assert!(text.lines()[0].is_empty());
1428        assert_eq!(text.lines()[1].to_plain_text(), "hello");
1429    }
1430
1431    #[test]
1432    fn line_with_span_ownership() {
1433        // Verify that spans are properly owned
1434        let s = String::from("hello");
1435        let line = Line::raw(&s);
1436        drop(s); // Original string dropped
1437        assert_eq!(line.to_plain_text(), "hello"); // Still works
1438    }
1439
1440    // ==========================================================================
1441    // Cow<str> ownership behavior tests
1442    // ==========================================================================
1443
1444    #[test]
1445    fn span_cow_borrowed_from_static() {
1446        let span = Span::raw("static");
1447        assert!(matches!(span.content, Cow::Borrowed(_)));
1448    }
1449
1450    #[test]
1451    fn span_cow_owned_from_string() {
1452        let span = Span::raw(String::from("owned"));
1453        assert!(matches!(span.content, Cow::Owned(_)));
1454    }
1455
1456    #[test]
1457    fn span_into_owned_converts_borrowed() {
1458        let span = Span::raw("borrowed");
1459        assert!(matches!(span.content, Cow::Borrowed(_)));
1460
1461        let owned = span.into_owned();
1462        assert!(matches!(owned.content, Cow::Owned(_)));
1463        assert_eq!(owned.as_str(), "borrowed");
1464    }
1465
1466    #[test]
1467    fn span_with_link_into_owned() {
1468        let span = Span::raw("text").link("https://example.com");
1469        let owned = span.into_owned();
1470        assert!(owned.link.is_some());
1471        assert!(matches!(owned.link.as_ref().unwrap(), Cow::Owned(_)));
1472    }
1473
1474    // ==========================================================================
1475    // Span additional tests
1476    // ==========================================================================
1477
1478    #[test]
1479    fn span_link_method() {
1480        let span = Span::raw("click me").link("https://example.com");
1481        assert_eq!(span.link.as_deref(), Some("https://example.com"));
1482    }
1483
1484    #[test]
1485    fn span_measurement() {
1486        let span = Span::raw("hello");
1487        let m = span.measurement();
1488        assert_eq!(m.minimum, 5);
1489        assert_eq!(m.maximum, 5);
1490    }
1491
1492    #[test]
1493    fn span_is_empty() {
1494        assert!(Span::raw("").is_empty());
1495        assert!(!Span::raw("x").is_empty());
1496    }
1497
1498    #[test]
1499    fn span_default_is_empty() {
1500        let span = Span::default();
1501        assert!(span.is_empty());
1502        assert!(span.style.is_none());
1503        assert!(span.link.is_none());
1504    }
1505
1506    #[test]
1507    fn span_with_style() {
1508        let style = Style::new().bold();
1509        let span = Span::raw("text").with_style(style);
1510        assert_eq!(span.style, Some(style));
1511    }
1512
1513    #[test]
1514    fn span_from_segment() {
1515        let style = Style::new().italic();
1516        let seg = Segment::styled("hello", style);
1517        let span: Span = seg.into();
1518        assert_eq!(span.as_str(), "hello");
1519        assert_eq!(span.style, Some(style));
1520    }
1521
1522    #[test]
1523    fn span_debug_impl() {
1524        let span = Span::raw("test");
1525        let debug = format!("{:?}", span);
1526        assert!(debug.contains("Span"));
1527        assert!(debug.contains("test"));
1528    }
1529
1530    // ==========================================================================
1531    // Line additional tests
1532    // ==========================================================================
1533
1534    #[test]
1535    fn line_measurement() {
1536        let line = Line::raw("hello world");
1537        let m = line.measurement();
1538        assert_eq!(m.minimum, 11);
1539        assert_eq!(m.maximum, 11);
1540    }
1541
1542    #[test]
1543    fn line_from_empty_string_is_empty() {
1544        let line: Line = String::new().into();
1545        assert!(line.is_empty());
1546        assert_eq!(line.width(), 0);
1547    }
1548
1549    #[test]
1550    fn line_width_combining_mark_is_single_cell() {
1551        let line = Line::raw("e\u{301}");
1552        assert_eq!(line.width(), 1);
1553    }
1554
1555    #[test]
1556    fn line_wrap_handles_wide_grapheme_with_tiny_width() {
1557        let line = Line::raw("你好");
1558        let wrapped = line.wrap(1, WrapMode::Char);
1559        assert_eq!(wrapped.len(), 2);
1560        assert_eq!(wrapped[0].to_plain_text(), "你");
1561        assert_eq!(wrapped[1].to_plain_text(), "好");
1562    }
1563
1564    #[test]
1565    fn line_iter() {
1566        let line = Line::from_spans([Span::raw("a"), Span::raw("b"), Span::raw("c")]);
1567        let collected: Vec<_> = line.iter().collect();
1568        assert_eq!(collected.len(), 3);
1569    }
1570
1571    #[test]
1572    fn line_into_segments() {
1573        let style = Style::new().bold();
1574        let line = Line::from_spans([Span::raw("hello"), Span::styled(" world", style)]);
1575        let segments = line.into_segments();
1576        assert_eq!(segments.len(), 2);
1577        assert_eq!(segments[0].style, None);
1578        assert_eq!(segments[1].style, Some(style));
1579    }
1580
1581    #[test]
1582    fn line_into_segment_line() {
1583        let line = Line::raw("test");
1584        let seg_line = line.into_segment_line();
1585        assert_eq!(seg_line.to_plain_text(), "test");
1586    }
1587
1588    #[test]
1589    fn line_with_span_builder() {
1590        let line = Line::raw("hello ").with_span(Span::raw("world"));
1591        assert_eq!(line.to_plain_text(), "hello world");
1592    }
1593
1594    #[test]
1595    fn line_from_span() {
1596        let span = Span::styled("test", Style::new().bold());
1597        let line: Line = span.into();
1598        assert_eq!(line.to_plain_text(), "test");
1599    }
1600
1601    #[test]
1602    fn line_debug_impl() {
1603        let line = Line::raw("test");
1604        let debug = format!("{:?}", line);
1605        assert!(debug.contains("Line"));
1606    }
1607
1608    #[test]
1609    fn line_default_is_empty() {
1610        let line = Line::default();
1611        assert!(line.is_empty());
1612    }
1613
1614    // ==========================================================================
1615    // Text additional tests
1616    // ==========================================================================
1617
1618    #[test]
1619    fn text_style_returns_first_span_style() {
1620        let style = Style::new().bold();
1621        let text = Text::styled("hello", style);
1622        assert_eq!(text.style(), Some(style));
1623    }
1624
1625    #[test]
1626    fn text_style_returns_none_for_empty() {
1627        let text = Text::new();
1628        assert!(text.style().is_none());
1629    }
1630
1631    #[test]
1632    fn text_style_returns_none_for_unstyled() {
1633        let text = Text::raw("plain");
1634        assert!(text.style().is_none());
1635    }
1636
1637    #[test]
1638    fn text_with_line_builder() {
1639        let text = Text::raw("line 1").with_line(Line::raw("line 2"));
1640        assert_eq!(text.height(), 2);
1641    }
1642
1643    #[test]
1644    fn text_with_span_builder() {
1645        let text = Text::raw("hello ").with_span(Span::raw("world"));
1646        assert_eq!(text.to_plain_text(), "hello world");
1647    }
1648
1649    #[test]
1650    fn text_with_base_style_builder() {
1651        let text = Text::raw("test").with_base_style(Style::new().bold());
1652        assert!(
1653            text.lines()[0].spans()[0]
1654                .style
1655                .unwrap()
1656                .has_attr(StyleFlags::BOLD)
1657        );
1658    }
1659
1660    #[test]
1661    fn text_height_as_u16() {
1662        let text = Text::raw("a\nb\nc");
1663        assert_eq!(text.height_as_u16(), 3);
1664    }
1665
1666    #[test]
1667    fn text_height_as_u16_saturates() {
1668        // Create text with more than u16::MAX lines would saturate
1669        // Just verify the method exists and works for normal cases
1670        let text = Text::new();
1671        assert_eq!(text.height_as_u16(), 0);
1672    }
1673
1674    #[test]
1675    fn text_measurement() {
1676        let text = Text::raw("short\nlonger line");
1677        let m = text.measurement();
1678        assert_eq!(m.minimum, 11); // "longer line"
1679        assert_eq!(m.maximum, 11);
1680    }
1681
1682    #[test]
1683    fn text_from_segments_with_newlines() {
1684        let segments = vec![
1685            Segment::text("line 1"),
1686            Segment::newline(),
1687            Segment::text("line 2"),
1688        ];
1689        let text = Text::from_segments(segments);
1690        assert_eq!(text.height(), 2);
1691        assert_eq!(text.lines()[0].to_plain_text(), "line 1");
1692        assert_eq!(text.lines()[1].to_plain_text(), "line 2");
1693    }
1694
1695    #[test]
1696    fn text_converts_to_segment_lines_multiline() {
1697        let text = Text::raw("a\nb");
1698        let seg_lines = text.into_segment_lines();
1699        assert_eq!(seg_lines.len(), 2);
1700    }
1701
1702    #[test]
1703    fn text_iter() {
1704        let text = Text::from_lines([Line::raw("a"), Line::raw("b")]);
1705        let collected: Vec<_> = text.iter().collect();
1706        assert_eq!(collected.len(), 2);
1707    }
1708
1709    #[test]
1710    fn text_debug_impl() {
1711        let text = Text::raw("test");
1712        let debug = format!("{:?}", text);
1713        assert!(debug.contains("Text"));
1714    }
1715
1716    #[test]
1717    fn text_default_is_empty() {
1718        let text = Text::default();
1719        assert!(text.is_empty());
1720    }
1721
1722    // ==========================================================================
1723    // Truncation edge cases
1724    // ==========================================================================
1725
1726    #[test]
1727    fn truncate_ellipsis_wider_than_max() {
1728        let mut text = Text::raw("ab");
1729        text.truncate(2, Some("...")); // ellipsis is 3 wide, max is 2
1730        // Should truncate without ellipsis since ellipsis doesn't fit
1731        assert!(text.width() <= 2);
1732    }
1733
1734    #[test]
1735    fn truncate_exact_width_no_change() {
1736        let mut text = Text::raw("hello");
1737        text.truncate(5, Some("..."));
1738        assert_eq!(text.to_plain_text(), "hello"); // Exact fit, no truncation needed
1739    }
1740
1741    #[test]
1742    fn truncate_multiple_spans() {
1743        let text = Text::from_spans([
1744            Span::raw("hello "),
1745            Span::styled("world", Style::new().bold()),
1746        ]);
1747        let truncated = text.truncated(8, None);
1748        assert_eq!(truncated.to_plain_text(), "hello wo");
1749    }
1750
1751    #[test]
1752    fn truncate_preserves_link() {
1753        let mut text =
1754            Text::from_spans([Span::raw("click ").link("https://a.com"), Span::raw("here")]);
1755        text.truncate(6, None);
1756        // Link should be preserved on first span
1757        assert!(text.lines()[0].spans()[0].link.is_some());
1758    }
1759
1760    // ==========================================================================
1761    // Push span on empty text
1762    // ==========================================================================
1763
1764    #[test]
1765    fn push_span_on_empty_creates_line() {
1766        let mut text = Text::new();
1767        text.push_span(Span::raw("hello"));
1768        assert_eq!(text.height(), 1);
1769        assert_eq!(text.to_plain_text(), "hello");
1770    }
1771
1772    // ==========================================================================
1773    // From iterator tests
1774    // ==========================================================================
1775
1776    #[test]
1777    fn text_ref_into_iter() {
1778        let text = Text::from_lines([Line::raw("a"), Line::raw("b")]);
1779        let mut count = 0;
1780        for _line in &text {
1781            count += 1;
1782        }
1783        assert_eq!(count, 2);
1784    }
1785
1786    #[test]
1787    fn line_ref_into_iter() {
1788        let line = Line::from_spans([Span::raw("a"), Span::raw("b")]);
1789        let mut count = 0;
1790        for _span in &line {
1791            count += 1;
1792        }
1793        assert_eq!(count, 2);
1794    }
1795}
1796
1797#[cfg(test)]
1798mod proptests {
1799    use super::*;
1800    use proptest::prelude::*;
1801
1802    proptest! {
1803        #[test]
1804        fn raw_text_roundtrips(s in "[a-zA-Z0-9 \n]{0,100}") {
1805            let text = Text::raw(&s);
1806            let plain = text.to_plain_text();
1807            prop_assert_eq!(plain, s);
1808        }
1809
1810        #[test]
1811        fn truncate_never_exceeds_width(s in "[a-zA-Z0-9]{1,50}", max_width in 1usize..20) {
1812            let mut text = Text::raw(&s);
1813            text.truncate(max_width, None);
1814            prop_assert!(text.width() <= max_width);
1815        }
1816
1817        #[test]
1818        fn truncate_with_ellipsis_never_exceeds_width(s in "[a-zA-Z0-9]{1,50}", max_width in 4usize..20) {
1819            let mut text = Text::raw(&s);
1820            text.truncate(max_width, Some("..."));
1821            prop_assert!(text.width() <= max_width);
1822        }
1823
1824        #[test]
1825        fn height_equals_newline_count_plus_one(s in "[a-zA-Z\n]{1,100}") {
1826            let text = Text::raw(&s);
1827            let newline_count = s.chars().filter(|&c| c == '\n').count();
1828            prop_assert_eq!(text.height(), newline_count + 1);
1829        }
1830
1831        #[test]
1832        fn from_segments_preserves_content(
1833            parts in prop::collection::vec("[a-z]{1,10}", 1..5)
1834        ) {
1835            let segments: Vec<Segment> = parts.iter()
1836                .map(|s| Segment::text(s.as_str()))
1837                .collect();
1838
1839            let text = Text::from_segments(segments);
1840            let plain = text.to_plain_text();
1841            let expected: String = parts.join("");
1842
1843            prop_assert_eq!(plain, expected);
1844        }
1845    }
1846}