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}