Skip to main content

uncurses/text/
mode.rs

1//! Width-measurement and wrapping policy, plus grapheme-cell iteration.
2//!
3//! Strings are always segmented into extended grapheme clusters by
4//! [`grapheme_cells`]. [`WidthMode`] changes only how each cluster's cell width
5//! is computed: by summing its code points (`Wc`) or by cluster-aware Unicode
6//! presentation rules (`Grapheme`). [`WrapMode`] controls what happens when a
7//! cluster would run past the right edge of a clip rectangle.
8
9use crate::unicode::graphemes;
10
11use super::width::{char_width, grapheme_width};
12
13/// How grapheme clusters are measured for terminal-cell layout.
14///
15/// This enum does not control segmentation: [`grapheme_cells`] always
16/// segments by extended grapheme cluster. It controls only width calculation
17/// for each cluster. The East-Asian Ambiguous policy is orthogonal and is
18/// passed as a separate `eaw_wide` boolean to [`char_width`] and
19/// [`grapheme_width`].
20#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum WidthMode {
22    /// Measure each cluster by summing the widths of its code points.
23    ///
24    /// This models a terminal that has no grapheme segmentation and advances
25    /// the cursor once per code point, which is what POSIX `wcswidth`
26    /// describes: the sum of `wcwidth` over the string. Combining marks,
27    /// joiners, and variation selectors are themselves zero-width, so they
28    /// add nothing and a cluster like `e` + combining acute still measures
29    /// one cell. A code point that owns cells does add, which is why a
30    /// regional-indicator pair measures two.
31    ///
32    /// A long joined sequence measures the sum of its parts, so a
33    /// four-person family emoji is eight columns and a skin-toned thumbs-up
34    /// is four. Use this when matching older or simpler terminal width
35    /// behavior is more important than cluster-aware emoji presentation.
36    #[default]
37    Wc,
38    /// Measure the whole grapheme cluster.
39    ///
40    /// This mode accounts for variation selectors, regional indicators,
41    /// zero-width-joiner sequences, and pictographic presentation when
42    /// deciding whether a cluster occupies zero, one, or two cells. Use it for
43    /// modern terminal rendering that treats emoji and joined clusters as a
44    /// single displayed unit.
45    Grapheme,
46}
47
48impl WidthMode {
49    /// Measure one extended grapheme cluster under this mode.
50    ///
51    /// In [`WidthMode::Wc`] mode, the width is the sum of [`char_width`] over
52    /// every code point in `g`; an empty string is `0`. In
53    /// [`WidthMode::Grapheme`] mode, the width is [`grapheme_width`] for the
54    /// whole cluster.
55    ///
56    /// # Parameters
57    ///
58    /// * `g` — an extended grapheme cluster. Passing a longer string is
59    ///   accepted, and in `Wc` mode every code point in it counts toward the
60    ///   sum.
61    /// * `eaw_wide` — East-Asian Ambiguous policy; see [`char_width`].
62    ///
63    /// # Returns
64    ///
65    /// The cluster width in terminal cells. [`WidthMode::Grapheme`] returns
66    /// `0`, `1`, or `2`; [`WidthMode::Wc`] sums code points and can return
67    /// more, saturating at [`u8::MAX`].
68    ///
69    /// # Errors and panics
70    ///
71    /// This method does not fail or intentionally panic.
72    pub fn grapheme_width(self, g: &str, eaw_wide: bool) -> u8 {
73        match self {
74            Self::Wc => g.chars().fold(0u8, |total, c| {
75                total.saturating_add(char_width(c, eaw_wide))
76            }),
77            Self::Grapheme => grapheme_width(g, eaw_wide),
78        }
79    }
80}
81
82/// Iterate `s` as `(grapheme_cluster, width)` pairs.
83///
84/// Segmentation is always by extended grapheme cluster. Width calculation uses
85/// [`WidthMode::grapheme_width`] with the supplied East-Asian Ambiguous policy.
86/// The string slice in each yielded pair borrows from `s`.
87///
88/// # Parameters
89///
90/// * `s` — UTF-8 string to segment and measure.
91/// * `mode` — cluster-width policy.
92/// * `eaw_wide` — East-Asian Ambiguous policy.
93///
94/// # Returns
95///
96/// An iterator yielding borrowed cluster slices and their display widths.
97///
98/// # Errors and panics
99///
100/// This function does not fail or intentionally panic.
101pub fn grapheme_cells(
102    s: &str,
103    mode: WidthMode,
104    eaw_wide: bool,
105) -> impl Iterator<Item = (&str, u8)> {
106    graphemes(s).map(move |g| (g, mode.grapheme_width(g, eaw_wide)))
107}
108
109/// Behavior when a cluster would extend past the right edge of the clip
110/// rectangle.
111///
112/// Newlines and carriage returns are handled independently of this setting:
113/// `\n` advances to the next row at the clip rectangle's left edge, and `\r`
114/// returns to that left edge on the current row.
115#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
116pub enum WrapMode {
117    /// Stop painting at the right edge of the current row.
118    ///
119    /// The cluster that would cross the edge is not written, and the returned
120    /// cursor position is where painting stopped.
121    #[default]
122    Truncate,
123    /// Continue on the next row at the left edge of the clip rectangle.
124    ///
125    /// Wrapping stops when the bottom edge is reached. If a cluster is wider
126    /// than the clip rectangle itself, it is not written.
127    Wrap,
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn wc_mode_sums_codepoint_widths() {
136        // 'e' + combining acute: 1 + 0. A zero-width tail adds nothing, so
137        // the common accented-letter case still measures one cell.
138        assert_eq!(WidthMode::Wc.grapheme_width("e\u{0301}", false), 1);
139        // Lone combining mark cluster: nothing to add up.
140        assert_eq!(WidthMode::Wc.grapheme_width("\u{0301}", false), 0);
141        // Empty string.
142        assert_eq!(WidthMode::Wc.grapheme_width("", false), 0);
143    }
144
145    #[test]
146    fn wc_mode_counts_codepoints_past_the_first() {
147        // A regional-indicator pair is two code points of one cell each, and
148        // a terminal without grapheme segmentation advances two columns for
149        // it. Measuring only the first code point would report one and leave
150        // every later column on the row off by one.
151        assert_eq!(WidthMode::Wc.grapheme_width("\u{1f1fa}\u{1f1f8}", false), 2);
152        // A lone regional indicator is genuinely one column.
153        assert_eq!(WidthMode::Wc.grapheme_width("\u{1f1ef}", false), 1);
154        // A zero-width code point followed by a printable one: the cluster
155        // occupies the printable one's cell, not zero.
156        assert_eq!(WidthMode::Wc.grapheme_width("\u{200d}a", false), 1);
157    }
158
159    #[test]
160    fn wc_mode_reports_the_full_sum_for_joined_sequences() {
161        // A terminal measuring per code point advances two columns for each
162        // emoji in the sequence and none for the joiners.
163        assert_eq!(WidthMode::Wc.grapheme_width("\u{1f44d}\u{1f3fd}", false), 4);
164        let family = "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}";
165        assert_eq!(WidthMode::Wc.grapheme_width(family, false), 8);
166    }
167
168    #[test]
169    fn grapheme_mode_uses_cluster_width() {
170        // ✋ + VS15: Grapheme honours VS15 → text presentation, width 1.
171        assert_eq!(
172            WidthMode::Grapheme.grapheme_width("\u{270b}\u{fe0e}", false),
173            1
174        );
175        // Wc reads no presentation meaning in VS15; it is a zero-width code
176        // point, so the sum is '✋' alone (width 2 under the default
177        // emoji-presentation tables).
178        assert_eq!(WidthMode::Wc.grapheme_width("\u{270b}\u{fe0e}", false), 2);
179    }
180
181    #[test]
182    fn east_asian_width_flag_applies_to_both_modes() {
183        assert_eq!(WidthMode::Wc.grapheme_width("…", false), 1);
184        assert_eq!(WidthMode::Wc.grapheme_width("…", true), 2);
185        assert_eq!(WidthMode::Grapheme.grapheme_width("…", false), 1);
186        assert_eq!(WidthMode::Grapheme.grapheme_width("…", true), 2);
187    }
188
189    #[test]
190    fn grapheme_cells_segments_both_modes_by_cluster() {
191        let g: Vec<_> = grapheme_cells("Aé中", WidthMode::Grapheme, false).collect();
192        assert_eq!(g.len(), 3);
193        assert_eq!(g[2], ("中", 2));
194
195        // Wc mode also segments by cluster; widths come from first char.
196        let w: Vec<_> = grapheme_cells("e\u{0301}A", WidthMode::Wc, false).collect();
197        assert_eq!(w.len(), 2);
198        assert_eq!(w[0], ("e\u{0301}", 1));
199        assert_eq!(w[1], ("A", 1));
200    }
201}