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}