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 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}