Skip to main content

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}