retroglyph_core/text.rs
1//! Styled text primitives: [`Span`](crate::text::Span) and [`Line`](crate::text::Line).
2
3use crate::color::Style;
4use alloc::string::String;
5use alloc::vec::Vec;
6use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
7
8/// The number of terminal cells `s` occupies, saturating at `u16::MAX`.
9///
10/// Wide characters (CJK, most emoji) count as two columns; combining marks
11/// and most control characters count as zero. [`Span::width`](crate::text::Span::width) and
12/// [`Line::width`](crate::text::Line::width) are built on this function; reach for it directly to
13/// measure a borrowed `&str` without constructing either type first.
14///
15/// See [`width_usize`] for the unsaturated measurement.
16///
17/// # Examples
18///
19/// ```
20/// use retroglyph_core::text::width;
21///
22/// assert_eq!(width("hello"), 5);
23/// assert_eq!(width("中文"), 4); // each CJK char is 2 columns
24/// ```
25#[must_use]
26pub fn width(s: &str) -> u16 {
27 #[allow(clippy::cast_possible_truncation)] // clamped to u16::MAX above
28 let w = width_usize(s).min(usize::from(u16::MAX)) as u16;
29 w
30}
31
32/// The number of terminal cells `s` occupies, without saturating to `u16`.
33///
34/// Prefer [`width`] when the result feeds a `u16`-based geometry type such
35/// as `Rect`/`Size`; use this when the raw `unicode-width` measurement is
36/// needed instead.
37#[must_use]
38pub fn width_usize(s: &str) -> usize {
39 s.width()
40}
41
42/// The number of terminal cells a single character occupies.
43///
44/// Returns `1` for most characters, including control characters (`unicode-width` reports no
45/// width for these; `1` matches what [`Surface`](crate::surface::Surface) actually draws and what
46/// [`Tile::width`](crate::tile::Tile::width) tells the terminal to advance by, so this function agrees
47/// with the rest of the crate instead of undercounting), `2` for wide characters (CJK, most
48/// emoji), and `0` for combining marks (these genuinely occupy no column of their own).
49///
50/// # Examples
51///
52/// ```
53/// use retroglyph_core::text::char_width;
54///
55/// assert_eq!(char_width('a'), 1);
56/// assert_eq!(char_width('中'), 2);
57/// assert_eq!(char_width('\u{0301}'), 0); // combining acute accent
58/// assert_eq!(char_width('\u{7}'), 1); // BEL: a control character, not a combining mark
59/// ```
60#[must_use]
61pub fn char_width(c: char) -> u16 {
62 #[allow(clippy::cast_possible_truncation)] // unicode-width never returns > 2
63 let w = c.width().unwrap_or(1) as u16;
64 w
65}
66
67/// The number of trailing characters [`split_at_width`] re-measures with [`width_usize`] when
68/// deciding whether the next character extends the current display cluster. Generous headroom
69/// over any real `UnicodeWidthStr` boundary effect (the longest are ZWJ emoji sequences and
70/// flag/tag runs of well under a dozen codepoints), chosen to keep that re-measurement bounded
71/// instead of rescanning the whole prefix on every character.
72const CLUSTER_LOOKBACK: usize = 32;
73
74/// Splits `s` at the byte index where its display width reaches `max_cols`.
75///
76/// Splits on a whole-character boundary; a character that would push the
77/// total over `max_cols` is left in the second half along with the rest of
78/// `s`. Returns `(prefix, rest)` such that `width(prefix) <= max_cols` and
79/// `prefix` is the longest prefix of `s` for which that holds.
80///
81/// Each candidate prefix is measured with [`width_usize`] (the same
82/// `UnicodeWidthStr` logic behind the postcondition above), not a sum of
83/// individual [`char_width`]s: `UnicodeWidthStr` measures some multi-codepoint
84/// clusters (emoji presentation/ZWJ/modifier sequences) as a unit whose width
85/// differs from the sum of its parts, and per-char summing would let such a
86/// cluster violate the postcondition in either direction.
87///
88/// That re-measurement is bounded to a trailing window of the last `CLUSTER_LOOKBACK`
89/// characters rather than the whole prefix seen so far:
90/// `UnicodeWidthStr`'s boundary effects never reach further back than a
91/// handful of codepoints (variation selectors, a single ZWJ join, an emoji
92/// modifier, or a short flag/tag run), so a bounded window reproduces the
93/// same result as re-measuring from byte `0` while keeping this function
94/// linear in `s`'s length instead of quadratic.
95///
96/// # Examples
97///
98/// ```
99/// use retroglyph_core::text::split_at_width;
100///
101/// assert_eq!(split_at_width("hello world", 5), ("hello", " world"));
102/// assert_eq!(split_at_width("hi", 10), ("hi", ""));
103/// ```
104#[must_use]
105pub fn split_at_width(s: &str, max_cols: u16) -> (&str, &str) {
106 let (end, _cols) = split_at_width_indexed(s, max_cols);
107 s.split_at(end)
108}
109
110/// The shared scan behind [`split_at_width`] and [`truncate_measured`]: walks `s` once and
111/// returns the byte index where its display width reaches `max_cols`, alongside the prefix's own
112/// display width up to that index (always `<= max_cols`). Both callers need this same walk;
113/// [`truncate_measured`] hands the `cols` half back to its caller instead of re-measuring the
114/// prefix with a second `width` pass over it.
115fn split_at_width_indexed(s: &str, max_cols: u16) -> (usize, usize) {
116 let max_cols = usize::from(max_cols);
117 let mut end = 0usize;
118 let mut cols = 0usize;
119 for (i, ch) in s.char_indices() {
120 let candidate_end = i + ch.len_utf8();
121 let window_start = s[..end]
122 .char_indices()
123 .rev()
124 .nth(CLUSTER_LOOKBACK - 1)
125 .map_or(0, |(idx, _)| idx);
126 let committed = width_usize(&s[window_start..end]);
127 let extended = width_usize(&s[window_start..candidate_end]);
128 let delta = extended.saturating_sub(committed);
129 if cols + delta > max_cols {
130 break;
131 }
132 cols += delta;
133 end = candidate_end;
134 }
135 (end, cols)
136}
137
138/// Splits `s` at the byte index where its display width reaches `max_cols`, returning the
139/// prefix's own measured width alongside it.
140///
141/// Equivalent to `let (prefix, _) = split_at_width(s, max_cols); (prefix, width(prefix))`, except
142/// the width is read back from [`split_at_width`]'s own internal accounting instead of
143/// re-measuring `prefix` with a second pass over it: reach for this instead of that pair whenever
144/// the caller needs the truncated width right after truncating (which is every truncate call in
145/// this crate's own callers), rather than only the truncated text.
146///
147/// # Examples
148///
149/// ```
150/// use retroglyph_core::text::truncate_measured;
151///
152/// assert_eq!(truncate_measured("hello world", 5), ("hello", 5));
153/// assert_eq!(truncate_measured("a\u{4e2d}b", 2), ("a", 1)); // "\u{4e2d}" (CJK) doesn't fit
154/// ```
155#[must_use]
156pub fn truncate_measured(s: &str, max_cols: u16) -> (&str, u16) {
157 let (end, cols) = split_at_width_indexed(s, max_cols);
158 #[allow(clippy::cast_possible_truncation)] // cols <= max_cols, a u16
159 let cols = cols as u16;
160 (&s[..end], cols)
161}
162
163/// A string with an associated [`Style`](crate::color::Style).
164///
165/// The building block of styled terminal output. A [`Line`](crate::text::Line) is composed of
166/// one or more `Span`s, each with its own style.
167///
168/// # Examples
169///
170/// ```
171/// use retroglyph_core::text::Span;
172/// use retroglyph_core::color::Style;
173/// use retroglyph_core::color::Color;
174///
175/// let plain = Span::raw("hello");
176/// let colored = Span::styled("world", Style::new().fg(Color::GREEN));
177/// ```
178#[derive(Debug, Clone, PartialEq, Eq, Hash)]
179pub struct Span {
180 /// The text content.
181 pub content: String,
182 /// The style applied to this span.
183 pub style: Style,
184}
185
186impl Span {
187 /// Creates a span with the given content and no styling.
188 #[must_use]
189 pub fn raw(content: impl Into<String>) -> Self {
190 Self {
191 content: content.into(),
192 style: Style::default(),
193 }
194 }
195
196 /// Creates a span with the given content and style.
197 #[must_use]
198 pub fn styled(content: impl Into<String>, style: Style) -> Self {
199 Self {
200 content: content.into(),
201 style,
202 }
203 }
204
205 /// Returns the display width of this span in terminal columns.
206 #[must_use]
207 pub fn width(&self) -> usize {
208 width_usize(&self.content)
209 }
210}
211
212impl<S: Into<String>> From<S> for Span {
213 fn from(s: S) -> Self {
214 Self::raw(s)
215 }
216}
217
218/// A horizontal sequence of [`Span`](crate::text::Span)s rendered as a single line.
219///
220/// # Examples
221///
222/// ```
223/// use retroglyph_core::text::{Line, Span};
224/// use retroglyph_core::color::Style;
225/// use retroglyph_core::color::Color;
226///
227/// let line = Line::from(vec![
228/// Span::raw("HP: "),
229/// Span::styled("100", Style::new().fg(Color::GREEN)),
230/// ]);
231/// assert_eq!(line.width(), 7);
232/// ```
233#[derive(Debug, Clone, PartialEq, Eq, Default)]
234pub struct Line {
235 /// The spans that make up this line.
236 pub spans: Vec<Span>,
237}
238
239impl Line {
240 /// Creates an empty line.
241 #[must_use]
242 pub fn new() -> Self {
243 Self::default()
244 }
245
246 /// Creates a line from a single unstyled string.
247 #[must_use]
248 pub fn raw(content: impl Into<String>) -> Self {
249 Self {
250 spans: alloc::vec![Span::raw(content)],
251 }
252 }
253
254 /// Returns the total display width of this line in terminal columns.
255 ///
256 /// Accounts for wide characters (CJK, emoji) that occupy two columns.
257 #[must_use]
258 pub fn width(&self) -> usize {
259 self.spans.iter().map(Span::width).sum()
260 }
261}
262
263impl From<&str> for Line {
264 fn from(s: &str) -> Self {
265 Self::raw(s)
266 }
267}
268
269impl From<String> for Line {
270 fn from(s: String) -> Self {
271 Self::raw(s)
272 }
273}
274
275impl From<Span> for Line {
276 fn from(span: Span) -> Self {
277 Self {
278 spans: alloc::vec![span],
279 }
280 }
281}
282
283impl From<Vec<Span>> for Line {
284 fn from(spans: Vec<Span>) -> Self {
285 Self { spans }
286 }
287}
288
289/// Build a [`Line`](crate::text::Line) from a list of `(Style, text)` pairs.
290///
291/// Each pair becomes a [`Span`](crate::text::Span) with the given style. The resulting `Line`
292/// is equivalent to calling `Line::from(vec![Span::styled(t, s), ...])` but
293/// with less boilerplate for multi-segment event-log text.
294///
295/// # Examples
296///
297/// ```
298/// # extern crate alloc;
299/// use retroglyph_core::spans;
300/// use retroglyph_core::color::Style;
301/// use retroglyph_core::color::Color;
302///
303/// let line = spans![
304/// (Style::new().fg(Color::CYAN), "snowtroop "),
305/// (Style::default(), "→ 1 hit"),
306/// ];
307/// assert_eq!(line.width(), 17);
308/// ```
309#[macro_export]
310macro_rules! spans {
311 ($(($style:expr, $text:expr)),* $(,)?) => {
312 $crate::text::Line::from(alloc::vec![
313 $($crate::text::Span::styled($text, $style)),*
314 ])
315 };
316}
317
318#[cfg(test)]
319mod tests {
320 use proptest::prelude::*;
321
322 use super::*;
323 use crate::color::Color;
324
325 #[test]
326 fn width_matches_span_width() {
327 assert_eq!(width("hello"), 5);
328 assert_eq!(width(""), 0);
329 }
330
331 #[test]
332 fn width_counts_wide_characters_as_two_columns() {
333 assert_eq!(width("中文"), 4);
334 }
335
336 #[test]
337 fn width_saturates_at_u16_max() {
338 let s = "a".repeat(usize::from(u16::MAX) + 100);
339 assert_eq!(width(&s), u16::MAX);
340 assert_eq!(width_usize(&s), s.len());
341 }
342
343 #[test]
344 fn char_width_matches_unicode_width() {
345 assert_eq!(char_width('a'), 1);
346 assert_eq!(char_width('中'), 2);
347 assert_eq!(char_width('\u{0301}'), 0); // combining acute accent
348 }
349
350 #[test]
351 fn char_width_treats_control_characters_as_one_column() {
352 // `unicode-width` reports no width for control characters (`None`), but `Surface` and
353 // `Tile::width` both already advance the cursor by 1 column when one is drawn; `char_width`
354 // agrees with them instead of the combining-mark case above.
355 assert_eq!(char_width('\u{7}'), 1); // BEL
356 assert_eq!(char_width('\n'), 1);
357 assert_eq!(char_width('\t'), 1);
358 }
359
360 #[test]
361 fn split_at_width_stops_at_the_column_budget() {
362 assert_eq!(split_at_width("hello world", 5), ("hello", " world"));
363 assert_eq!(split_at_width("hi", 10), ("hi", ""));
364 assert_eq!(split_at_width("hi", 0), ("", "hi"));
365 }
366
367 #[test]
368 fn split_at_width_counts_wide_characters_as_two_columns() {
369 assert_eq!(split_at_width("aあb", 2), ("a", "あb"));
370 assert_eq!(split_at_width("aあb", 3), ("aあ", "b"));
371 assert_eq!(split_at_width("ああ", 3), ("あ", "あ"));
372 }
373
374 #[test]
375 fn split_at_width_prefix_can_exceed_max_cols() {
376 // "❤️" (U+2764 heart + U+FE0F variation selector-16): unicode-width
377 // measures the pair as 2 columns even though the per-char widths are
378 // 1 + 0 = 1, so the whole cluster must not slip through a 1-column
379 // budget.
380 let (prefix, _rest) = split_at_width("\u{2764}\u{FE0F}", 1);
381 assert!(width(prefix) <= 1);
382 }
383
384 #[test]
385 fn split_at_width_returns_the_longest_prefix_that_fits() {
386 // "👍🏽" (U+1F44D thumbs-up + U+1F3FD skin-tone modifier): unicode-width
387 // measures the pair as 2 columns, so the whole string fits a 2-column
388 // budget intact, even though the per-char widths sum to 2 + 2 = 4.
389 let thumbs = "\u{1F44D}\u{1F3FD}";
390 assert_eq!(split_at_width(thumbs, width(thumbs)), (thumbs, ""));
391 }
392
393 #[test]
394 fn truncate_measured_matches_split_at_width_plus_a_separate_measurement() {
395 assert_eq!(truncate_measured("hello world", 5), ("hello", 5));
396 assert_eq!(truncate_measured("hi", 10), ("hi", 2));
397 assert_eq!(truncate_measured("aあb", 2), ("a", 1));
398 assert_eq!(truncate_measured("aあb", 3), ("aあ", 3));
399 }
400
401 proptest! {
402 /// `split_at_width`'s own documented postcondition: the returned prefix's display width
403 /// never exceeds the requested budget, across arbitrary input strings and budgets (not
404 /// just the handful of hand-picked cases above).
405 #[test]
406 fn split_at_width_prefix_never_exceeds_max_cols(s in ".*", max_cols in 0u16..64) {
407 let (prefix, _rest) = split_at_width(&s, max_cols);
408 prop_assert!(width(prefix) <= max_cols);
409 }
410
411 /// [`truncate_measured`] reports the same width its returned prefix actually measures at,
412 /// i.e. it isn't taking a shortcut that quietly drifts from a real `width` call.
413 #[test]
414 fn truncate_measured_width_matches_a_direct_measurement(s in ".*", max_cols in 0u16..64) {
415 let (prefix, reported) = truncate_measured(&s, max_cols);
416 prop_assert_eq!(reported, width(prefix));
417 }
418 }
419
420 #[test]
421 fn test_span_raw() {
422 let s = Span::raw("hello");
423 assert_eq!(s.content, "hello");
424 assert_eq!(s.style, Style::default());
425 assert_eq!(s.width(), 5);
426 }
427
428 #[test]
429 fn test_span_styled() {
430 let style = Style::new().fg(Color::RED);
431 let s = Span::styled("hi", style);
432 assert_eq!(s.content, "hi");
433 assert_eq!(s.style, style);
434 }
435
436 #[test]
437 fn test_span_width_wide_chars() {
438 let s = Span::raw("中文"); // each CJK char is 2 columns
439 assert_eq!(s.width(), 4);
440 }
441
442 #[test]
443 fn test_line_from_str() {
444 let line = Line::from("hello");
445 assert_eq!(line.spans.len(), 1);
446 assert_eq!(line.width(), 5);
447 }
448
449 #[test]
450 fn test_line_from_spans() {
451 use alloc::vec;
452
453 let line = Line::from(vec![
454 Span::raw("HP: "),
455 Span::styled("100", Style::new().fg(Color::GREEN)),
456 ]);
457 assert_eq!(line.width(), 7);
458 }
459
460 #[test]
461 fn test_line_width_wide_chars() {
462 use alloc::vec;
463
464 let line = Line::from(vec![Span::raw("中"), Span::raw("x")]);
465 assert_eq!(line.width(), 3); // 2 + 1
466 }
467
468 #[test]
469 fn test_line_empty() {
470 let line = Line::new();
471 assert_eq!(line.width(), 0);
472 }
473}