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 the first code point (`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 the width of its first code point.
23 ///
24 /// This is wcwidth-style and intentionally cluster-blind: variation
25 /// selectors, zero-width joiners, and emoji presentation overrides in the
26 /// rest of the cluster do not change the result. Use this when matching
27 /// older or simpler terminal width behavior is more important than
28 /// cluster-aware emoji presentation.
29 #[default]
30 Wc,
31 /// Measure the whole grapheme cluster.
32 ///
33 /// This mode accounts for variation selectors, regional indicators,
34 /// zero-width-joiner sequences, and pictographic presentation when
35 /// deciding whether a cluster occupies zero, one, or two cells. Use it for
36 /// modern terminal rendering that treats emoji and joined clusters as a
37 /// single displayed unit.
38 Grapheme,
39}
40
41impl WidthMode {
42 /// Measure one extended grapheme cluster under this mode.
43 ///
44 /// In [`WidthMode::Wc`] mode, the width is the [`char_width`] of `g`'s
45 /// first code point, or `0` for an empty string. In
46 /// [`WidthMode::Grapheme`] mode, the width is [`grapheme_width`] for the
47 /// whole cluster.
48 ///
49 /// # Parameters
50 ///
51 /// * `g` — an extended grapheme cluster. Passing a longer string is
52 /// accepted but only the first code point is considered in `Wc` mode.
53 /// * `eaw_wide` — East-Asian Ambiguous policy; see [`char_width`].
54 ///
55 /// # Returns
56 ///
57 /// The cluster width in terminal cells, normally `0`, `1`, or `2`.
58 ///
59 /// # Errors and panics
60 ///
61 /// This method does not fail or intentionally panic.
62 pub fn grapheme_width(self, g: &str, eaw_wide: bool) -> u8 {
63 match self {
64 Self::Wc => g.chars().next().map_or(0, |c| char_width(c, eaw_wide)),
65 Self::Grapheme => grapheme_width(g, eaw_wide),
66 }
67 }
68}
69
70/// Iterate `s` as `(grapheme_cluster, width)` pairs.
71///
72/// Segmentation is always by extended grapheme cluster. Width calculation uses
73/// [`WidthMode::grapheme_width`] with the supplied East-Asian Ambiguous policy.
74/// The string slice in each yielded pair borrows from `s`.
75///
76/// # Parameters
77///
78/// * `s` — UTF-8 string to segment and measure.
79/// * `mode` — cluster-width policy.
80/// * `eaw_wide` — East-Asian Ambiguous policy.
81///
82/// # Returns
83///
84/// An iterator yielding borrowed cluster slices and their display widths.
85///
86/// # Errors and panics
87///
88/// This function does not fail or intentionally panic.
89pub fn grapheme_cells(
90 s: &str,
91 mode: WidthMode,
92 eaw_wide: bool,
93) -> impl Iterator<Item = (&str, u8)> {
94 graphemes(s).map(move |g| (g, mode.grapheme_width(g, eaw_wide)))
95}
96
97/// Behavior when a cluster would extend past the right edge of the clip
98/// rectangle.
99///
100/// Newlines and carriage returns are handled independently of this setting:
101/// `\n` advances to the next row at the clip rectangle's left edge, and `\r`
102/// returns to that left edge on the current row.
103#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
104pub enum WrapMode {
105 /// Stop painting at the right edge of the current row.
106 ///
107 /// The cluster that would cross the edge is not written, and the returned
108 /// cursor position is where painting stopped.
109 #[default]
110 Truncate,
111 /// Continue on the next row at the left edge of the clip rectangle.
112 ///
113 /// Wrapping stops when the bottom edge is reached. If a cluster is wider
114 /// than the clip rectangle itself, it is not written.
115 Wrap,
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn wc_mode_uses_first_codepoint_width() {
124 // 'e' + combining acute: first char 'e' → 1.
125 assert_eq!(WidthMode::Wc.grapheme_width("e\u{0301}", false), 1);
126 // Lone combining mark cluster: first char width 0.
127 assert_eq!(WidthMode::Wc.grapheme_width("\u{0301}", false), 0);
128 }
129
130 #[test]
131 fn grapheme_mode_uses_cluster_width() {
132 // ✋ + VS15: Grapheme honours VS15 → text presentation, width 1.
133 assert_eq!(
134 WidthMode::Grapheme.grapheme_width("\u{270b}\u{fe0e}", false),
135 1
136 );
137 // Wc ignores the cluster and just measures '✋' alone (width 2
138 // under the default emoji-presentation tables).
139 assert_eq!(WidthMode::Wc.grapheme_width("\u{270b}\u{fe0e}", false), 2);
140 }
141
142 #[test]
143 fn east_asian_width_flag_applies_to_both_modes() {
144 assert_eq!(WidthMode::Wc.grapheme_width("…", false), 1);
145 assert_eq!(WidthMode::Wc.grapheme_width("…", true), 2);
146 assert_eq!(WidthMode::Grapheme.grapheme_width("…", false), 1);
147 assert_eq!(WidthMode::Grapheme.grapheme_width("…", true), 2);
148 }
149
150 #[test]
151 fn grapheme_cells_segments_both_modes_by_cluster() {
152 let g: Vec<_> = grapheme_cells("Aé中", WidthMode::Grapheme, false).collect();
153 assert_eq!(g.len(), 3);
154 assert_eq!(g[2], ("中", 2));
155
156 // Wc mode also segments by cluster; widths come from first char.
157 let w: Vec<_> = grapheme_cells("e\u{0301}A", WidthMode::Wc, false).collect();
158 assert_eq!(w.len(), 2);
159 assert_eq!(w[0], ("e\u{0301}", 1));
160 assert_eq!(w[1], ("A", 1));
161 }
162}