Skip to main content

urushi/text/
styled.rs

1//! Styled plain text before it enters layout.
2
3use std::fmt;
4use std::ops::Range;
5
6use unicode_segmentation::UnicodeSegmentation;
7
8use super::tab::DEFAULT_TAB_POLICY;
9use super::{Grapheme, TabPolicy};
10use crate::TextStyle;
11
12/// One caller-authored piece of text carrying one complete style.
13///
14/// A span is an input segment, not a layout boundary. [`StyledText`] joins all
15/// spans before it decides grapheme, wrapping, or clipping boundaries.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct TextSpan {
18    text: String,
19    style: TextStyle,
20}
21
22impl TextSpan {
23    /// Creates one caller-authored segment.
24    pub fn new(text: impl Into<String>, style: TextStyle) -> Self {
25        Self {
26            text: text.into(),
27            style,
28        }
29    }
30
31    /// Returns this segment's source text.
32    pub fn text(&self) -> &str {
33        &self.text
34    }
35
36    /// Returns the complete style assigned to this segment.
37    pub const fn style(&self) -> &TextStyle {
38        &self.style
39    }
40}
41
42impl From<String> for TextSpan {
43    fn from(text: String) -> Self {
44        Self::new(text, TextStyle::new())
45    }
46}
47
48impl From<&str> for TextSpan {
49    fn from(text: &str) -> Self {
50        Self::new(text, TextStyle::new())
51    }
52}
53
54/// A grapheme boundary between two input spans was invalid.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct StyledTextError {
57    span: usize,
58    byte_offset: usize,
59}
60
61impl StyledTextError {
62    /// The zero-based input span whose end split a grapheme cluster.
63    pub const fn span(&self) -> usize {
64        self.span
65    }
66
67    /// The offending byte offset in the concatenated text.
68    pub const fn byte_offset(&self) -> usize {
69        self.byte_offset
70    }
71}
72
73impl fmt::Display for StyledTextError {
74    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
75        write!(
76            formatter,
77            "text span {} ends at byte {}, inside a grapheme cluster",
78            self.span, self.byte_offset
79        )
80    }
81}
82
83impl std::error::Error for StyledTextError {}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86struct SpanRange {
87    range: Range<usize>,
88    style: TextStyle,
89}
90
91/// One text flow carrying any number of styles.
92///
93/// The source is stored once. Its private ranges are canonical: they are
94/// non-empty, cover the source in order, end only at whole-string grapheme
95/// boundaries, and never place equal styles beside each other. Newline and
96/// horizontal tab are admitted source controls; layout replaces tabs under
97/// this value's policy, while direct text rendering preserves them.
98#[derive(Debug, Clone, Default, PartialEq, Eq)]
99pub struct StyledText {
100    text: String,
101    spans: Vec<SpanRange>,
102    tab_policy: Option<TabPolicy>,
103}
104
105impl StyledText {
106    /// Creates one uniformly styled text flow.
107    ///
108    /// # Panics
109    ///
110    /// Panics when `text` contains a control character other than newline or
111    /// horizontal tab. Rendered ANSI belongs to the output domain, not this
112    /// source-text domain.
113    pub fn new(text: impl Into<String>, style: TextStyle) -> Self {
114        let text = text.into();
115        validate_source(&text);
116        let spans = (!text.is_empty())
117            .then_some(SpanRange {
118                range: 0..text.len(),
119                style,
120            })
121            .into_iter()
122            .collect();
123        Self {
124            text,
125            spans,
126            tab_policy: None,
127        }
128    }
129
130    /// Joins input segments into one validated, canonical text flow.
131    ///
132    /// # Panics
133    ///
134    /// Panics when any segment contains a control character other than newline
135    /// or horizontal tab.
136    pub fn try_from_spans<I, S>(spans: I) -> Result<Self, StyledTextError>
137    where
138        I: IntoIterator<Item = S>,
139        S: Into<TextSpan>,
140    {
141        let spans: Vec<TextSpan> = spans.into_iter().map(Into::into).collect();
142        let mut text = String::new();
143        let mut boundaries = Vec::new();
144        for (index, span) in spans.iter().enumerate() {
145            text.push_str(&span.text);
146            if !span.text.is_empty() && index + 1 < spans.len() {
147                boundaries.push((index, text.len()));
148            }
149        }
150        validate_source(&text);
151
152        let grapheme_boundaries: Vec<usize> = text
153            .grapheme_indices(true)
154            .map(|(offset, _)| offset)
155            .chain(std::iter::once(text.len()))
156            .collect();
157        if let Some((span, byte_offset)) = boundaries
158            .into_iter()
159            .find(|(_, offset)| grapheme_boundaries.binary_search(offset).is_err())
160        {
161            return Err(StyledTextError { span, byte_offset });
162        }
163
164        let mut ranges: Vec<SpanRange> = Vec::new();
165        let mut offset = 0;
166        for span in spans {
167            let start = offset;
168            offset += span.text.len();
169            if start == offset {
170                continue;
171            }
172            if let Some(previous) = ranges.last_mut()
173                && previous.style == span.style
174            {
175                previous.range.end = offset;
176            } else {
177                ranges.push(SpanRange {
178                    range: start..offset,
179                    style: span.style,
180                });
181            }
182        }
183        Ok(Self {
184            text,
185            spans: ranges,
186            tab_policy: None,
187        })
188    }
189
190    /// Returns the joined plain-text source.
191    pub fn as_str(&self) -> &str {
192        &self.text
193    }
194
195    /// Returns the canonical styled segments in source order.
196    pub fn spans(&self) -> impl Iterator<Item = (&str, &TextStyle)> {
197        self.spans
198            .iter()
199            .map(|span| (&self.text[span.range.clone()], &span.style))
200    }
201
202    /// Sets the policy used when this text participates in layout.
203    ///
204    /// Direct text rendering preserves the source tab characters and ignores
205    /// this layout-only property.
206    pub fn tab_policy(mut self, tab_policy: TabPolicy) -> Self {
207        self.tab_policy = Some(tab_policy);
208        self
209    }
210
211    /// Restores the default layout policy of four spaces per tab.
212    pub fn reset_tab_policy(mut self) -> Self {
213        self.tab_policy = None;
214        self
215    }
216
217    /// Returns the explicitly configured layout policy, if any.
218    pub const fn get_tab_policy(&self) -> Option<&TabPolicy> {
219        self.tab_policy.as_ref()
220    }
221
222    /// Splits the whole text into rows of styled graphemes.
223    pub(crate) fn lines(&self) -> Vec<Vec<StyledTextGrapheme<'_>>> {
224        let policy = self.tab_policy.as_ref().unwrap_or(&DEFAULT_TAB_POLICY);
225        let mut lines = Vec::new();
226        let mut line = Vec::new();
227        let mut span = 0;
228        for (offset, symbol) in self.text.grapheme_indices(true) {
229            if symbol == "\n" {
230                lines.push(std::mem::take(&mut line));
231                continue;
232            }
233            while self.spans[span].range.end <= offset {
234                span += 1;
235            }
236            if symbol == "\t" {
237                append_tab(&mut line, policy, &self.spans[span].style);
238                continue;
239            }
240            line.push(StyledTextGrapheme {
241                grapheme: Grapheme::new(symbol),
242                style: &self.spans[span].style,
243            });
244        }
245        if !line.is_empty() || !self.text.ends_with('\n') {
246            lines.push(line);
247        }
248        if lines.is_empty() {
249            lines.push(Vec::new());
250        }
251        lines
252    }
253
254    pub(crate) fn uniform_style(&self) -> Option<&TextStyle> {
255        (self.spans.len() == 1).then(|| &self.spans[0].style)
256    }
257}
258
259fn validate_source(text: &str) {
260    assert!(
261        !text
262            .chars()
263            .any(|character| character.is_control() && character != '\n' && character != '\t'),
264        "styled text must not carry terminal control characters other than newline and tab: {text:?}"
265    );
266}
267
268fn append_tab<'a>(
269    line: &mut Vec<StyledTextGrapheme<'a>>,
270    policy: &'a TabPolicy,
271    style: &'a TextStyle,
272) {
273    let mut occupied = 0;
274    if let Some(marker) = policy.marker() {
275        for symbol in marker.graphemes(true) {
276            let grapheme = Grapheme::new(symbol);
277            occupied += grapheme.width();
278            line.push(StyledTextGrapheme { grapheme, style });
279        }
280    }
281    for _ in occupied..usize::from(policy.width()) {
282        line.push(StyledTextGrapheme {
283            grapheme: Grapheme::space(),
284            style,
285        });
286    }
287}
288
289impl From<String> for StyledText {
290    fn from(text: String) -> Self {
291        Self::new(text, TextStyle::new())
292    }
293}
294
295impl From<&str> for StyledText {
296    fn from(text: &str) -> Self {
297        Self::new(text, TextStyle::new())
298    }
299}
300
301#[derive(Debug, Clone, Copy)]
302pub(crate) struct StyledTextGrapheme<'a> {
303    pub grapheme: &'a Grapheme,
304    pub style: &'a TextStyle,
305}
306
307impl StyledTextGrapheme<'_> {
308    pub fn width(self) -> usize {
309        self.grapheme.width()
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::Color;
317
318    #[test]
319    fn strings_become_default_spans() {
320        let owned = TextSpan::from(String::from("owned"));
321        let borrowed = TextSpan::from("borrowed");
322
323        assert_eq!(owned, TextSpan::new("owned", TextStyle::new()));
324        assert_eq!(borrowed, TextSpan::new("borrowed", TextStyle::new()));
325    }
326
327    #[test]
328    fn canonicalizes_empty_and_adjacent_equal_spans() {
329        let red = TextStyle::new().foreground(Color::RED);
330        let text = StyledText::try_from_spans([
331            TextSpan::new("a", red.clone()),
332            TextSpan::new("", TextStyle::new()),
333            TextSpan::new("b", red.clone()),
334        ])
335        .unwrap();
336
337        assert_eq!(text.as_str(), "ab");
338        assert_eq!(text.spans().collect::<Vec<_>>(), [("ab", &red)]);
339    }
340
341    #[test]
342    fn rejects_a_span_boundary_inside_a_whole_text_grapheme() {
343        let error = StyledText::try_from_spans([
344            TextSpan::new("e", TextStyle::new()),
345            TextSpan::new("\u{301}", TextStyle::new().bold()),
346        ])
347        .unwrap_err();
348
349        assert_eq!(error.span(), 0);
350        assert_eq!(error.byte_offset(), 1);
351    }
352
353    #[test]
354    fn assigns_styles_after_segmenting_the_whole_text() {
355        let red = TextStyle::new().foreground(Color::RED);
356        let text = StyledText::try_from_spans([
357            TextSpan::new("日", red.clone()),
358            TextSpan::new("👩‍💻", TextStyle::new()),
359        ])
360        .unwrap();
361        let lines = text.lines();
362
363        assert_eq!(lines[0].len(), 2);
364        assert_eq!(lines[0][0].grapheme.as_str(), "日");
365        assert_eq!(lines[0][0].style, &red);
366        assert_eq!(lines[0][1].grapheme.as_str(), "👩‍💻");
367    }
368}