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}