Skip to main content

ctl_text/
line.rs

1//! One line of styled spans.
2
3use std::fmt;
4
5use anstyle::Style;
6use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete, UnicodeSegmentation};
7
8use crate::cells::{cluster_width, is_space};
9use crate::span::Span;
10
11/// One grapheme cluster with the style of its span.
12#[derive(Clone, Copy, Debug)]
13pub(crate) struct Piece<'a> {
14    pub(crate) text: &'a str,
15    pub(crate) style: Style,
16    pub(crate) width: u16,
17    pub(crate) space: bool,
18}
19
20/// A list of spans that sit on one terminal line.
21///
22/// The text of a line is the text of its spans, joined. Its [`Display`]
23/// prints that text without styles, which suits tests and plain output.
24/// Build one with `Line::from(span)`, or collect spans into it.
25///
26/// [`Display`]: fmt::Display
27#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
28pub struct Line {
29    spans: Vec<Span>,
30}
31
32impl Line {
33    /// An empty line.
34    #[must_use]
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Adds `span` at the end of the line.
40    ///
41    /// A span in the style of the line's last span joins it. So two lines
42    /// with the same text and styles are equal, however their spans were
43    /// pushed.
44    ///
45    /// The start of `span` can continue the line's last grapheme cluster, as
46    /// a combining mark or the rest of a ZWJ emoji sequence does. Those
47    /// characters join the previous span and take its style, because a
48    /// terminal cannot style part of a cluster.
49    ///
50    /// So no cluster crosses a span boundary, and every width counts the
51    /// cluster once. An empty span adds nothing, so none can sit between a
52    /// cluster's parts.
53    pub fn push(&mut self, span: Span) {
54        if span.text.is_empty() {
55            return;
56        }
57        match self.spans.last_mut() {
58            Some(last) if last.style == span.style => last.text.push_str(&span.text),
59            Some(_) | None => self.push_new(span),
60        }
61    }
62
63    /// The spans, in order. Their text joined is the text of the line.
64    #[must_use]
65    pub fn spans(&self) -> &[Span] {
66        &self.spans
67    }
68
69    /// The width of the line in terminal cells. A width over `u16::MAX`
70    /// saturates.
71    #[must_use]
72    pub fn width(&self) -> u16 {
73        self.spans
74            .iter()
75            .map(Span::width)
76            .fold(0, u16::saturating_add)
77    }
78
79    /// Whether the line holds no text.
80    #[must_use]
81    pub fn is_empty(&self) -> bool {
82        self.spans.iter().all(|span| span.text.is_empty())
83    }
84
85    /// Removes the spaces at the end of the line, and any span they empty.
86    ///
87    /// A no-break space (U+00A0, U+2007, or U+202F) is not a space here, so it
88    /// stays.
89    pub fn trim_end(&mut self) {
90        while let Some(last) = self.spans.last_mut() {
91            let trailing = trailing_space_len(&last.text);
92            if trailing == last.text.len() {
93                self.spans.pop();
94            } else {
95                let kept = last.text.len() - trailing;
96                last.text.truncate(kept);
97                break;
98            }
99        }
100    }
101
102    /// Adds `text` in `style`, joining the last span when it has the same
103    /// style. `text` must already be safe.
104    pub(crate) fn append(&mut self, text: &str, style: Style) {
105        if text.is_empty() {
106            return;
107        }
108        match self.spans.last_mut() {
109            Some(last) if last.style == style => last.text.push_str(text),
110            Some(_) | None => self.push_new(Span {
111                text: text.to_owned(),
112                style,
113            }),
114        }
115    }
116
117    /// Adds a non-empty `span` whose style differs from the last span's.
118    fn push_new(&mut self, span: Span) {
119        self.spans.push(span);
120        self.keep_clusters_whole();
121    }
122
123    /// Moves the start of the last span into the span before it when that
124    /// start continues the earlier span's last cluster. The cluster keeps the
125    /// style it started in.
126    fn keep_clusters_whole(&mut self) {
127        let [.., before, last] = self.spans.as_mut_slice() else {
128            return;
129        };
130        let moved = continuation_len(&before.text, &last.text);
131        if moved == 0 {
132            return;
133        }
134        before.text.extend(last.text.drain(..moved));
135        if last.text.is_empty() {
136            self.spans.pop();
137        }
138    }
139
140    pub(crate) fn pieces(&self) -> Vec<Piece<'_>> {
141        self.spans
142            .iter()
143            .flat_map(|span| {
144                span.text.graphemes(true).map(|cluster| Piece {
145                    text: cluster,
146                    style: span.style,
147                    width: cluster_width(cluster),
148                    space: is_space(cluster),
149                })
150            })
151            .collect()
152    }
153
154    pub(crate) fn from_pieces(pieces: &[Piece<'_>]) -> Self {
155        let mut line = Self::new();
156        for piece in pieces {
157            line.append(piece.text, piece.style);
158        }
159        line
160    }
161}
162
163/// The byte length of the start of `after` that continues the last grapheme
164/// cluster of `before`, or 0 when `after` starts a new cluster.
165///
166/// A cursor over `before` followed by `after` decides the boundary at their
167/// join, so no joined copy of the text is built.
168fn continuation_len(before: &str, after: &str) -> usize {
169    let junction = before.len();
170    let mut cursor = GraphemeCursor::new(junction, junction + after.len(), true);
171    loop {
172        match cursor.is_boundary(after, junction) {
173            Ok(true)
174            | Err(
175                GraphemeIncomplete::PrevChunk
176                | GraphemeIncomplete::NextChunk
177                | GraphemeIncomplete::InvalidOffset,
178            ) => return 0,
179            Ok(false) => break,
180            Err(GraphemeIncomplete::PreContext(end)) => cursor.provide_context(&before[..end], 0),
181        }
182    }
183    loop {
184        match cursor.next_boundary(after, junction) {
185            Ok(Some(end)) => return end.saturating_sub(junction),
186            Ok(None) => return after.len(),
187            Err(GraphemeIncomplete::PreContext(end)) => cursor.provide_context(&before[..end], 0),
188            Err(
189                GraphemeIncomplete::PrevChunk
190                | GraphemeIncomplete::NextChunk
191                | GraphemeIncomplete::InvalidOffset,
192            ) => return 0,
193        }
194    }
195}
196
197/// The byte length of the clusters at the end of `text` that are all spaces.
198fn trailing_space_len(text: &str) -> usize {
199    text.graphemes(true)
200        .rev()
201        .take_while(|cluster| is_space(cluster))
202        .map(str::len)
203        .sum()
204}
205
206impl From<Span> for Line {
207    fn from(span: Span) -> Self {
208        let mut line = Self::new();
209        line.push(span);
210        line
211    }
212}
213
214impl Extend<Span> for Line {
215    fn extend<I: IntoIterator<Item = Span>>(&mut self, spans: I) {
216        for span in spans {
217            self.push(span);
218        }
219    }
220}
221
222impl FromIterator<Span> for Line {
223    fn from_iter<I: IntoIterator<Item = Span>>(spans: I) -> Self {
224        let mut line = Self::new();
225        line.extend(spans);
226        line
227    }
228}
229
230impl fmt::Display for Line {
231    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
232        self.spans
233            .iter()
234            .try_for_each(|span| formatter.write_str(&span.text))
235    }
236}