Skip to main content

ctl_text/
cells.rs

1//! Terminal cell widths, counted per extended grapheme cluster.
2
3use std::num::NonZeroU16;
4
5use unicode_segmentation::UnicodeSegmentation;
6use unicode_width::UnicodeWidthStr;
7
8/// The tab stop spacing used when a caller sets none: every 8 cells.
9// Built from the smallest non-zero value because a denied lint rules out
10// `NonZeroU16::new(8).unwrap()`.
11pub const TAB_WIDTH: NonZeroU16 = NonZeroU16::MIN.saturating_add(7);
12
13/// A Unicode version, such as 17.0.0.
14#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub struct UnicodeVersion {
16    /// The major version.
17    pub major: u64,
18    /// The minor version.
19    pub minor: u64,
20    /// The update version.
21    pub update: u64,
22}
23
24/// The Unicode versions of the two data sets that decide a width.
25///
26/// A cluster's boundaries come from `unicode-segmentation` and its width from
27/// `unicode-width`. The two crates ship their own tables, so they can sit on
28/// different Unicode versions. An emoji that one knows and the other does not
29/// is counted wrongly. The versions change only in a new release of this crate.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31#[non_exhaustive]
32pub struct UnicodeVersions {
33    /// The version of the width tables in `unicode-width`.
34    pub width: UnicodeVersion,
35    /// The version of the grapheme cluster tables in `unicode-segmentation`.
36    pub segmentation: UnicodeVersion,
37}
38
39/// A `u8` as a `u64`, because `From` cannot run in a constant.
40const fn widen(value: u8) -> u64 {
41    u64::from_le_bytes([value, 0, 0, 0, 0, 0, 0, 0])
42}
43
44/// The Unicode versions behind [`cluster_width`] and [`text_width`].
45pub const UNICODE_VERSIONS: UnicodeVersions = UnicodeVersions {
46    width: UnicodeVersion {
47        major: widen(unicode_width::UNICODE_VERSION.0),
48        minor: widen(unicode_width::UNICODE_VERSION.1),
49        update: widen(unicode_width::UNICODE_VERSION.2),
50    },
51    segmentation: UnicodeVersion {
52        major: unicode_segmentation::UNICODE_VERSION.0,
53        minor: unicode_segmentation::UNICODE_VERSION.1,
54        update: unicode_segmentation::UNICODE_VERSION.2,
55    },
56};
57
58/// The width in cells of `cluster`, taken as one extended grapheme cluster.
59///
60/// East Asian ambiguous characters count 1 cell. A ZWJ emoji sequence counts
61/// as one emoji, 2 cells, not the sum of its parts. A width over `u16::MAX`
62/// saturates.
63#[must_use]
64pub fn cluster_width(cluster: &str) -> u16 {
65    u16::try_from(UnicodeWidthStr::width(cluster)).unwrap_or(u16::MAX)
66}
67
68/// The width in cells of `text`: the sum of [`cluster_width`] over its
69/// extended grapheme clusters. A sum over `u16::MAX` saturates.
70///
71/// The sum is per cluster, so it can exceed the width a terminal gives a run
72/// that shapes into one glyph across clusters. The Arabic lam followed by
73/// alef is two clusters of 1 cell each, so 2, though a font may draw one
74/// ligature.
75#[must_use]
76pub fn text_width(text: &str) -> u16 {
77    text.graphemes(true)
78        .map(cluster_width)
79        .fold(0, u16::saturating_add)
80}
81
82/// The byte offset where the longest prefix of whole clusters that fits in
83/// `width` cells ends.
84///
85/// Slice with it to cut `text` safely: `&text[..prefix_len(text, width)]`.
86/// A cluster that would cross `width` stays out whole, so the prefix can be
87/// narrower than `width`. When all of `text` fits, the offset is `text.len()`.
88#[must_use]
89pub fn prefix_len(text: &str, width: u16) -> usize {
90    let mut used: u16 = 0;
91    for (offset, cluster) in text.grapheme_indices(true) {
92        used = used.saturating_add(cluster_width(cluster));
93        if used > width {
94            return offset;
95        }
96    }
97    text.len()
98}
99
100/// Whether `character` may end a word. That is whitespace other than a
101/// no-break space (U+00A0, U+2007, U+202F), or the zero width space (U+200B).
102/// The zero width space marks a place to break and shows nothing.
103pub(crate) fn is_break_space(character: char) -> bool {
104    (character.is_whitespace() && !matches!(character, '\u{a0}' | '\u{2007}' | '\u{202f}'))
105        || character == '\u{200b}'
106}
107
108/// Whether every character of `cluster` may end a word.
109pub(crate) fn is_space(cluster: &str) -> bool {
110    cluster.chars().all(is_break_space)
111}