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