Skip to main content

uncurses/text/
width.rs

1//! Code-point and grapheme-cluster display width helpers.
2//!
3//! The public functions in this module return terminal-cell widths. The
4//! implementation is selected at compile time: the default feature uses compact
5//! code-point width data with conservative property helpers, while the `icu`
6//! feature uses property data with broader Unicode coverage.
7
8/// Display width, in terminal cells, of a single code point.
9///
10/// This function is cluster-blind. It reports width for `c` alone: `0` for
11/// controls and zero-width formatting/mark code points, `2` for code points
12/// whose East-Asian-Width property is `Wide` or `Fullwidth`, and `1` for most
13/// other printable code points.
14///
15/// `eaw_wide` selects the East-Asian Ambiguous policy: when `true`, ambiguous
16/// code points, including many Greek, Cyrillic, and box-drawing characters, are
17/// reported as two cells; when `false`, they are reported as one.
18///
19/// # Parameters
20///
21/// * `c` — Unicode scalar value to measure.
22/// * `eaw_wide` — whether East-Asian Ambiguous code points count as wide.
23///
24/// # Returns
25///
26/// The display width in terminal cells.
27///
28/// # Errors and panics
29///
30/// This function does not fail or intentionally panic.
31pub fn char_width(c: char, eaw_wide: bool) -> u8 {
32    cp_width(c, eaw_wide)
33}
34
35/// Display width, in terminal cells, of one extended grapheme cluster.
36///
37/// This function is cluster-aware for the cases that affect terminal layout:
38/// it honors text/emoji variation selectors, regional indicators, zero-width
39/// joiner sequences, and pictographic default presentation. Non-pictographic
40/// clusters use the width of their base code point; combining marks,
41/// joiners, and variation selectors in the tail do not add cells.
42///
43/// # Parameters
44///
45/// * `g` — one extended grapheme cluster. An empty string has width `0`.
46/// * `eaw_wide` — East-Asian Ambiguous policy; see [`char_width`].
47///
48/// # Returns
49///
50/// The cluster width in terminal cells.
51///
52/// # Errors and panics
53///
54/// This function does not fail or intentionally panic.
55pub fn grapheme_width(g: &str, eaw_wide: bool) -> u8 {
56    let mut chars = g.chars();
57    let Some(first) = chars.next() else {
58        return 0;
59    };
60    let cp = first as u32;
61
62    // ASCII printable fast path.
63    if (0x20..0x7f).contains(&cp) {
64        return 1;
65    }
66    // C0 / DEL / C1 controls.
67    if cp < 0x20 || (0x7f..=0x9f).contains(&cp) {
68        return 0;
69    }
70    // Flag emoji: a Regional Indicator forms a width-2 cluster (RI+RI
71    // or even a lone RI — the latter is what most terminals render).
72    if is_regional_indicator(cp) {
73        return 2;
74    }
75    // Default-ignorable lone code points render as zero cells.
76    if is_default_ignorable(first) {
77        return 0;
78    }
79    // Extended_Pictographic: honour explicit VS overrides, otherwise
80    // fall back to the code point's default Emoji_Presentation.
81    if is_extended_pictographic(first) {
82        if g.contains('\u{fe0f}') {
83            return 2;
84        }
85        if g.contains('\u{fe0e}') {
86            return 1;
87        }
88        return if is_emoji_presentation(first) { 2 } else { 1 };
89    }
90    // Non-pictographic base: use the per-codepoint width of the base
91    // character. Combining marks / ZWJs / VSes in the tail are 0-width
92    // and don't change the cluster's cell count.
93    cp_width(first, eaw_wide)
94}
95
96// ---------------------------------------------------------------------------
97// Code-point width — backend-specific
98// ---------------------------------------------------------------------------
99
100#[cfg(all(not(feature = "icu"), feature = "unicode-rs"))]
101fn cp_width(c: char, eaw_wide: bool) -> u8 {
102    use unicode_width::UnicodeWidthChar;
103    let raw = if eaw_wide {
104        UnicodeWidthChar::width_cjk(c)
105    } else {
106        UnicodeWidthChar::width(c)
107    };
108    raw.unwrap_or(0).min(2) as u8
109}
110
111#[cfg(feature = "icu")]
112fn cp_width(c: char, eaw_wide: bool) -> u8 {
113    use icu_properties::CodePointMapData;
114    use icu_properties::props::{EastAsianWidth, GeneralCategory};
115
116    let cp = c as u32;
117    if cp < 0x20 || (0x7f..=0x9f).contains(&cp) {
118        return 0;
119    }
120    if is_default_ignorable(c) {
121        return 0;
122    }
123    let gc = CodePointMapData::<GeneralCategory>::new().get(c);
124    if matches!(
125        gc,
126        GeneralCategory::NonspacingMark | GeneralCategory::EnclosingMark | GeneralCategory::Format
127    ) {
128        return 0;
129    }
130    match CodePointMapData::<EastAsianWidth>::new().get(c) {
131        EastAsianWidth::Wide | EastAsianWidth::Fullwidth => 2,
132        EastAsianWidth::Ambiguous if eaw_wide => 2,
133        _ => 1,
134    }
135}
136
137// ---------------------------------------------------------------------------
138// Property helpers
139// ---------------------------------------------------------------------------
140
141#[inline]
142fn is_regional_indicator(cp: u32) -> bool {
143    (0x1f1e6..=0x1f1ff).contains(&cp)
144}
145
146#[cfg(feature = "icu")]
147fn is_default_ignorable(c: char) -> bool {
148    use icu_properties::CodePointSetData;
149    use icu_properties::props::DefaultIgnorableCodePoint;
150    CodePointSetData::new::<DefaultIgnorableCodePoint>().contains(c)
151}
152
153#[cfg(all(not(feature = "icu"), feature = "unicode-rs"))]
154fn is_default_ignorable(c: char) -> bool {
155    // Conservative subset that matters for terminal rendering: format
156    // controls and the variation-selector blocks. Full coverage would
157    // need a property table; callers wanting strict UAX behaviour
158    // should enable `--features icu`.
159    matches!(
160        c as u32,
161        0x00ad           // SOFT HYPHEN
162        | 0x034f         // COMBINING GRAPHEME JOINER
163        | 0x061c         // ARABIC LETTER MARK
164        | 0x115f..=0x1160// HANGUL CHOSEONG/JUNGSEONG FILLER
165        | 0x17b4..=0x17b5
166        | 0x180b..=0x180f
167        | 0x200b..=0x200f// ZWSP..RLM
168        | 0x202a..=0x202e
169        | 0x2060..=0x206f
170        | 0x3164
171        | 0xfe00..=0xfe0f// VS1..VS16
172        | 0xfeff
173        | 0xffa0
174        | 0xfff0..=0xfff8
175        | 0x1bca0..=0x1bca3
176        | 0x1d173..=0x1d17a
177        | 0xe0000..=0xe0fff
178    )
179}
180
181#[cfg(feature = "icu")]
182fn is_extended_pictographic(c: char) -> bool {
183    use icu_properties::CodePointSetData;
184    use icu_properties::props::ExtendedPictographic;
185    CodePointSetData::new::<ExtendedPictographic>().contains(c)
186}
187
188#[cfg(all(not(feature = "icu"), feature = "unicode-rs"))]
189fn is_extended_pictographic(c: char) -> bool {
190    // Best-effort coverage of the main Extended_Pictographic ranges
191    // (UTS #51 emoji-data). For UAX-strict behaviour use `--features icu`.
192    let cp = c as u32;
193    matches!(
194        cp,
195        0x00a9 | 0x00ae
196        | 0x203c | 0x2049
197        | 0x2122 | 0x2139
198        | 0x2194..=0x2199
199        | 0x21a9..=0x21aa
200        | 0x231a..=0x231b
201        | 0x2328
202        | 0x2388
203        | 0x23cf
204        | 0x23e9..=0x23f3
205        | 0x23f8..=0x23fa
206        | 0x24c2
207        | 0x25aa..=0x25ab
208        | 0x25b6
209        | 0x25c0
210        | 0x25fb..=0x25fe
211        | 0x2600..=0x27bf
212        | 0x2934..=0x2935
213        | 0x2b00..=0x2bff
214        | 0x3030
215        | 0x303d
216        | 0x3297
217        | 0x3299
218        | 0x1f000..=0x1ffff
219    )
220}
221
222#[cfg(feature = "icu")]
223fn is_emoji_presentation(c: char) -> bool {
224    use icu_properties::CodePointSetData;
225    use icu_properties::props::EmojiPresentation;
226    CodePointSetData::new::<EmojiPresentation>().contains(c)
227}
228
229#[cfg(all(not(feature = "icu"), feature = "unicode-rs"))]
230fn is_emoji_presentation(c: char) -> bool {
231    // Best-effort: code points whose default presentation is emoji
232    // (UTS #51 Emoji_Presentation = Yes). Coarse subset; for the full
233    // property use `--features icu`.
234    let cp = c as u32;
235    matches!(
236        cp,
237        0x231a..=0x231b
238        | 0x23e9..=0x23ec
239        | 0x23f0
240        | 0x23f3
241        | 0x25fd..=0x25fe
242        | 0x2614..=0x2615
243        | 0x2648..=0x2653
244        | 0x267f
245        | 0x2693
246        | 0x26a1
247        | 0x26aa..=0x26ab
248        | 0x26bd..=0x26be
249        | 0x26c4..=0x26c5
250        | 0x26ce
251        | 0x26d4
252        | 0x26ea
253        | 0x26f2..=0x26f3
254        | 0x26f5
255        | 0x26fa
256        | 0x26fd
257        | 0x2705
258        | 0x270a..=0x270b
259        | 0x2728
260        | 0x274c
261        | 0x274e
262        | 0x2753..=0x2755
263        | 0x2757
264        | 0x2795..=0x2797
265        | 0x27b0
266        | 0x27bf
267        | 0x2b1b..=0x2b1c
268        | 0x2b50
269        | 0x2b55
270        | 0x1f300..=0x1ffff
271    )
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    // ----- char_width -----
279
280    #[test]
281    fn ascii_is_one() {
282        for c in [' ', 'a', 'Z', '0', '~'] {
283            assert_eq!(char_width(c, false), 1);
284            assert_eq!(char_width(c, true), 1);
285        }
286    }
287
288    #[test]
289    fn controls_are_zero() {
290        for c in ['\t', '\n', '\r', '\0', '\x1b', '\x7f'] {
291            assert_eq!(char_width(c, false), 0);
292        }
293    }
294
295    #[test]
296    fn cjk_wide_is_two() {
297        assert_eq!(char_width('中', false), 2);
298        assert_eq!(char_width('中', true), 2);
299        assert_eq!(char_width('日', false), 2);
300    }
301
302    #[test]
303    fn east_asian_ambiguous_flag_flips_width() {
304        // U+2026 HORIZONTAL ELLIPSIS — EAW=Ambiguous.
305        assert_eq!(char_width('…', false), 1);
306        assert_eq!(char_width('…', true), 2);
307        // Box drawing (Ambiguous in EAW).
308        assert_eq!(char_width('─', false), 1);
309        assert_eq!(char_width('─', true), 2);
310    }
311
312    // ----- grapheme_width -----
313
314    #[test]
315    fn grapheme_ascii_is_one() {
316        assert_eq!(grapheme_width("A", false), 1);
317    }
318
319    #[test]
320    fn grapheme_cjk_is_two() {
321        assert_eq!(grapheme_width("中", false), 2);
322    }
323
324    #[test]
325    fn flag_emoji_is_two() {
326        // 🇺🇸 — Regional Indicator pair.
327        assert_eq!(grapheme_width("\u{1f1fa}\u{1f1f8}", false), 2);
328    }
329
330    #[test]
331    fn vs16_promotes_text_glyph_to_emoji() {
332        // ✋ + VS16 → emoji presentation, width 2.
333        assert_eq!(grapheme_width("\u{270b}\u{fe0f}", false), 2);
334    }
335
336    #[test]
337    fn vs15_demotes_emoji_to_text() {
338        // ✋ + VS15 → text presentation, width 1.
339        assert_eq!(grapheme_width("\u{270b}\u{fe0e}", false), 1);
340    }
341
342    #[test]
343    fn grapheme_ambiguous_respects_eaw() {
344        assert_eq!(grapheme_width("…", false), 1);
345        assert_eq!(grapheme_width("…", true), 2);
346        assert_eq!(grapheme_width("─", true), 2);
347    }
348
349    #[test]
350    fn combining_mark_does_not_change_base_width() {
351        // e + COMBINING ACUTE ACCENT (é) — one cell.
352        assert_eq!(grapheme_width("e\u{0301}", false), 1);
353    }
354}