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