Skip to main content

urushi/text/
printable.rs

1//! Plain text: strings that carry no terminal control information.
2
3use unicode_segmentation::UnicodeSegmentation;
4
5use super::width;
6
7/// Several lines of plain text, separated by `\n`.
8///
9/// A `&str` cannot say whether it holds plain text or already-rendered ANSI,
10/// and a function that guesses has to guess on every call. That guessing is
11/// what let escape handling leak into the plain path, so the property is
12/// carried by this type instead: every line it holds is a
13/// [`PrintableText`], and the `\n` between them is a separator rather than
14/// content.
15///
16/// It has no width. Width is a property of a row of cells, so it belongs to
17/// the lines this type splits into. Measuring across a line break would sum
18/// cells that never share a row.
19///
20/// Construction is where the caller declares the plain-text domain. Debug builds
21/// check the declaration; release builds
22/// take the caller's word for it.
23#[derive(Debug, PartialEq, Eq)]
24#[repr(transparent)]
25pub struct PrintableLines(str);
26
27impl PrintableLines {
28    /// Adopts plain text.
29    ///
30    /// # Panics
31    ///
32    /// In debug builds, panics when `text` holds a control character other
33    /// than `\n` — an escape sequence, a carriage return, a tab, a backspace.
34    /// Handing rendered output to a plain-text argument is a contract
35    /// violation, not a supported call with a degraded result: the layout pass
36    /// would measure the escapes as ordinary characters, and wrapping or
37    /// truncation would split them. Release builds do not check.
38    pub fn new(text: &str) -> &Self {
39        debug_assert!(
40            !text
41                .chars()
42                .any(|character| character.is_control() && character != '\n'),
43            "plain text must not carry terminal control characters: {text:?}"
44        );
45        Self::adopt(text)
46    }
47
48    /// Wraps a string already known to satisfy the invariant.
49    fn adopt(text: &str) -> &Self {
50        // SAFETY: `PrintableLines` is `repr(transparent)` over `str`, so the
51        // two have the same layout and the cast only changes the type.
52        unsafe { &*(std::ptr::from_ref::<str>(text) as *const Self) }
53    }
54
55    /// Splits into lines, yielding one empty line for empty text.
56    ///
57    /// A view's text always occupies at least one row, so an empty string
58    /// measures as one empty line rather than as no line at all.
59    pub fn lines(&self) -> Vec<&PrintableText> {
60        // The lines of a value that already satisfies this type's invariant
61        // satisfy the line invariant, so re-checking each one would turn one
62        // boundary check into a scan per line.
63        let lines: Vec<&PrintableText> = self.0.lines().map(PrintableText::adopt).collect();
64        if lines.is_empty() {
65            vec![PrintableText::adopt("")]
66        } else {
67            lines
68        }
69    }
70}
71
72/// One line of plain text: a row of cells, every character printable.
73///
74/// This is the unit measurement is defined on — it holds no control
75/// characters at all, not even `\n`. [`PrintableLines`] spans rows and
76/// therefore has no width; a `PrintableText` is exactly one row, so its cells
77/// come to a single number.
78#[derive(Debug, PartialEq, Eq)]
79#[repr(transparent)]
80pub struct PrintableText(str);
81
82impl PrintableText {
83    /// Adopts one line of plain text.
84    ///
85    /// # Panics
86    ///
87    /// In debug builds, panics when `text` holds any control character, `\n`
88    /// included — a line is one row of cells, so a line break inside it is as
89    /// much a contract violation as an escape sequence. Release builds do not
90    /// check.
91    pub fn new(text: &str) -> &Self {
92        debug_assert!(
93            !text.chars().any(char::is_control),
94            "a plain-text line must not carry control characters: {text:?}"
95        );
96        Self::adopt(text)
97    }
98
99    /// Wraps a string already known to satisfy the invariant.
100    fn adopt(text: &str) -> &Self {
101        // SAFETY: `PrintableText` is `repr(transparent)` over `str`, so the
102        // two have the same layout and the cast only changes the type.
103        unsafe { &*(std::ptr::from_ref::<str>(text) as *const Self) }
104    }
105
106    pub fn as_str(&self) -> &str {
107        &self.0
108    }
109
110    /// Returns the terminal cells this line occupies.
111    ///
112    /// This is the crate's one definition of display width: CJK ideographs
113    /// take two cells, an emoji ZWJ sequence takes two, and a combining mark
114    /// takes none. Because the receiver is a single line and cannot hold
115    /// escapes or cursor movement, nothing has to be scanned for or skipped.
116    ///
117    /// The cell rules are defined in one place inside the crate, which is
118    /// where a measure that depended on the terminal — ambiguous East Asian
119    /// width resolved as one cell or two — would take its input.
120    pub fn width(&self) -> usize {
121        width::text(self)
122    }
123
124    /// Cuts to at most `width` cells, on a grapheme boundary.
125    ///
126    /// A grapheme that would straddle the limit is dropped rather than split,
127    /// so the result never exceeds `width` and never leaves half a wide
128    /// character behind.
129    pub fn truncate(&self, width: usize) -> &Self {
130        if self.width() <= width {
131            return self;
132        }
133        let mut end = 0;
134        let mut consumed = 0;
135        for (offset, grapheme) in self.0.grapheme_indices(true) {
136            let grapheme_width = width::grapheme(Grapheme::adopt(grapheme));
137            if consumed + grapheme_width > width {
138                break;
139            }
140            consumed += grapheme_width;
141            end = offset + grapheme.len();
142        }
143        Self::adopt(&self.0[..end])
144    }
145
146    /// Splits into grapheme clusters, the unit a cell boundary may fall on.
147    pub fn graphemes(&self) -> impl Iterator<Item = &Grapheme> {
148        self.0.graphemes(true).map(Grapheme::adopt)
149    }
150}
151
152/// One grapheme cluster: the smallest run of text a cell boundary may fall on.
153///
154/// [`PrintableText`] is one row and [`PrintableLines`] spans rows, but neither
155/// says how far a single cell reaches. A cluster does, and that is the unit the
156/// layout pass turns into a token: a
157/// [`StyledGrapheme`](crate::StyledGrapheme) holds one of these and the cells
158/// it occupies, so a renderer that cannot split a cluster is a renderer that
159/// cannot disagree about a width.
160///
161/// The distinction is not decorative. A row of tokens whose widths sum to the
162/// rectangle's width still renders wrong if one token holds three clusters,
163/// because a backend writes a token into the single cell its width starts at.
164#[derive(Debug, PartialEq, Eq)]
165#[repr(transparent)]
166pub struct Grapheme(str);
167
168impl Grapheme {
169    /// Adopts one grapheme cluster.
170    ///
171    /// # Panics
172    ///
173    /// In debug builds, panics when `text` is not exactly one printable
174    /// grapheme cluster — empty text, several clusters, or a control
175    /// character. Release builds do not check.
176    pub fn new(text: &str) -> &Self {
177        debug_assert!(
178            !text.chars().any(char::is_control),
179            "a grapheme must not carry control characters: {text:?}"
180        );
181        debug_assert!(
182            text.graphemes(true).count() == 1,
183            "a grapheme must be exactly one cluster: {text:?}"
184        );
185        Self::adopt(text)
186    }
187
188    /// Wraps a string already known to satisfy the invariant.
189    fn adopt(text: &str) -> &Self {
190        // SAFETY: `Grapheme` is `repr(transparent)` over `str`, so the two
191        // have the same layout and the cast only changes the type.
192        unsafe { &*(std::ptr::from_ref::<str>(text) as *const Self) }
193    }
194
195    pub fn as_str(&self) -> &str {
196        &self.0
197    }
198
199    /// Returns the terminal cells this cluster occupies.
200    ///
201    /// Defined by the crate's shared width implementation, exactly
202    /// as [`PrintableText::width`] is.
203    pub fn width(&self) -> usize {
204        width::grapheme(self)
205    }
206
207    /// The one-cell blank the layout pass pads a rectangle with.
208    pub(crate) fn space() -> &'static Self {
209        Self::adopt(" ")
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn counts_wide_characters() {
219        assert_eq!(PrintableText::new("日本語").width(), 6);
220        assert_eq!(PrintableText::new("aあ").width(), 3);
221        assert_eq!(PrintableText::new("👩‍💻").width(), 2);
222        assert_eq!(PrintableText::new("e\u{301}").width(), 1);
223        assert_eq!(PrintableText::new("").width(), 0);
224    }
225
226    #[test]
227    fn empty_text_is_one_empty_line() {
228        assert_eq!(
229            PrintableLines::new("").lines(),
230            vec![PrintableText::new("")]
231        );
232        assert_eq!(
233            PrintableLines::new("a\nb").lines(),
234            vec![PrintableText::new("a"), PrintableText::new("b")]
235        );
236    }
237
238    #[test]
239    fn a_grapheme_measures_the_cells_of_its_own_cluster() {
240        assert_eq!(Grapheme::new("a").width(), 1);
241        assert_eq!(Grapheme::new("\u{3042}").width(), 2);
242        assert_eq!(Grapheme::new("\u{1F469}\u{200D}\u{1F4BB}").width(), 2);
243        assert_eq!(Grapheme::new("e\u{301}").width(), 1);
244    }
245
246    #[test]
247    #[should_panic(expected = "exactly one cluster")]
248    fn rejects_several_clusters_handed_to_a_grapheme() {
249        let _ = Grapheme::new("ab");
250    }
251
252    #[test]
253    #[should_panic(expected = "exactly one cluster")]
254    fn rejects_empty_text_handed_to_a_grapheme() {
255        let _ = Grapheme::new("");
256    }
257
258    #[test]
259    fn graphemes_keep_clusters_whole() {
260        let clusters: Vec<&str> = PrintableText::new("👩‍💻x")
261            .graphemes()
262            .map(Grapheme::as_str)
263            .collect();
264        assert_eq!(clusters, ["👩‍💻", "x"]);
265    }
266
267    #[test]
268    #[should_panic(expected = "terminal control characters")]
269    fn rejects_rendered_output_handed_to_the_plain_domain() {
270        let _ = PrintableLines::new("\x1b[31mred\x1b[0m");
271    }
272
273    #[test]
274    #[should_panic(expected = "terminal control characters")]
275    fn rejects_cursor_movement_in_plain_text() {
276        let _ = PrintableLines::new("a\tb");
277    }
278
279    #[test]
280    #[should_panic(expected = "must not carry control characters")]
281    fn rejects_a_line_break_inside_a_line() {
282        let _ = PrintableText::new("a\nb");
283    }
284
285    #[test]
286    fn truncation_drops_a_grapheme_that_would_straddle_the_limit() {
287        assert_eq!(PrintableText::new("A日本").truncate(3).as_str(), "A日");
288        assert_eq!(PrintableText::new("日本").truncate(1).as_str(), "");
289        assert_eq!(
290            PrintableText::new("e\u{301}x").truncate(1).as_str(),
291            "e\u{301}"
292        );
293    }
294}