rustyline/
layout.rs

1use std::cmp::Ordering;
2
3/// Tell how grapheme clusters are supported / rendered.
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5pub enum GraphemeClusterMode {
6    /// Support grapheme clustering
7    Unicode,
8    /// Doesn't support shaping
9    WcWidth,
10    /// Skip zero-width joiner
11    NoZwj,
12}
13
14impl GraphemeClusterMode {
15    /// Return default
16    #[cfg(test)]
17    pub fn from_env() -> Self {
18        GraphemeClusterMode::default()
19    }
20
21    /// Use environment variables to guess current mode
22    #[cfg(not(test))]
23    pub fn from_env() -> Self {
24        let gcm = match std::env::var("TERM_PROGRAM").as_deref() {
25            Ok("Apple_Terminal") => GraphemeClusterMode::Unicode,
26            Ok("iTerm.app") => GraphemeClusterMode::Unicode,
27            Ok("WezTerm") => GraphemeClusterMode::Unicode,
28            Err(std::env::VarError::NotPresent) => match std::env::var("TERM").as_deref() {
29                Ok("xterm-kitty") => GraphemeClusterMode::NoZwj,
30                _ => GraphemeClusterMode::WcWidth,
31            },
32            _ => GraphemeClusterMode::WcWidth,
33        };
34        log::debug!(target: "rustyline", "GraphemeClusterMode: {gcm:?}");
35        gcm
36    }
37
38    /// Grapheme with / number of columns
39    pub fn width(&self, s: &str) -> Unit {
40        match self {
41            GraphemeClusterMode::Unicode => uwidth(s),
42            GraphemeClusterMode::WcWidth => wcwidth(s),
43            GraphemeClusterMode::NoZwj => no_zwj(s),
44        }
45    }
46}
47
48#[cfg(test)]
49#[expect(clippy::derivable_impls)]
50impl Default for GraphemeClusterMode {
51    fn default() -> Self {
52        GraphemeClusterMode::Unicode
53    }
54}
55
56/// Height, width
57pub type Unit = u16;
58/// Character width / number of columns
59pub(crate) fn cwidh(c: char) -> Unit {
60    use unicode_width::UnicodeWidthChar;
61    Unit::try_from(c.width().unwrap_or(0)).unwrap()
62}
63
64fn uwidth(s: &str) -> Unit {
65    use unicode_width::UnicodeWidthStr;
66    Unit::try_from(s.width()).unwrap()
67}
68
69fn wcwidth(s: &str) -> Unit {
70    let mut width = 0;
71    for c in s.chars() {
72        width += cwidh(c);
73    }
74    width
75}
76
77const ZWJ: char = '\u{200D}';
78fn no_zwj(s: &str) -> Unit {
79    let mut width = 0;
80    for x in s.split(ZWJ) {
81        width += uwidth(x);
82    }
83    width
84}
85
86#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
87pub struct Position {
88    pub col: Unit, // The leftmost column is number 0.
89    pub row: Unit, // The highest row is number 0.
90}
91
92impl PartialOrd for Position {
93    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
94        Some(self.cmp(other))
95    }
96}
97
98impl Ord for Position {
99    fn cmp(&self, other: &Self) -> Ordering {
100        match self.row.cmp(&other.row) {
101            Ordering::Equal => self.col.cmp(&other.col),
102            o => o,
103        }
104    }
105}
106
107#[derive(Debug)]
108#[cfg_attr(test, derive(Default))]
109pub struct Layout {
110    pub grapheme_cluster_mode: GraphemeClusterMode,
111    /// Prompt Unicode/visible width and height
112    pub prompt_size: Position,
113    pub default_prompt: bool,
114    /// Cursor position (relative to the start of the prompt)
115    pub cursor: Position,
116    /// Number of rows used so far (from start of prompt to end of input)
117    pub end: Position,
118}
119
120impl Layout {
121    pub fn new(grapheme_cluster_mode: GraphemeClusterMode) -> Self {
122        Self {
123            grapheme_cluster_mode,
124            prompt_size: Position::default(),
125            default_prompt: false,
126            cursor: Position::default(),
127            end: Position::default(),
128        }
129    }
130
131    pub fn width(&self, s: &str) -> Unit {
132        self.grapheme_cluster_mode.width(s)
133    }
134}
135
136#[cfg(test)]
137mod test {
138    #[test]
139    fn unicode_width() {
140        assert_eq!(1, super::uwidth("a"));
141        assert_eq!(2, super::uwidth("๐Ÿ‘ฉโ€๐Ÿš€"));
142        assert_eq!(2, super::uwidth("๐Ÿ‘‹๐Ÿฟ"));
143        assert_eq!(2, super::uwidth("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"));
144        // iTerm2, Terminal.app KO
145        assert_eq!(2, super::uwidth("๐Ÿ‘ฉ๐Ÿผโ€๐Ÿ‘จ๐Ÿผโ€๐Ÿ‘ฆ๐Ÿผโ€๐Ÿ‘ฆ๐Ÿผ"));
146        // WezTerm KO, Terminal.app (rendered width = 1)
147        assert_eq!(2, super::uwidth("โค๏ธ"));
148    }
149    #[test]
150    fn test_wcwidth() {
151        assert_eq!(1, super::wcwidth("a"));
152        assert_eq!(4, super::wcwidth("๐Ÿ‘ฉโ€๐Ÿš€"));
153        assert_eq!(4, super::wcwidth("๐Ÿ‘‹๐Ÿฟ"));
154        assert_eq!(8, super::wcwidth("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"));
155        assert_eq!(16, super::wcwidth("๐Ÿ‘ฉ๐Ÿผโ€๐Ÿ‘จ๐Ÿผโ€๐Ÿ‘ฆ๐Ÿผโ€๐Ÿ‘ฆ๐Ÿผ"));
156        assert_eq!(1, super::wcwidth("โค๏ธ"));
157    }
158    #[test]
159    fn test_no_zwj() {
160        assert_eq!(1, super::no_zwj("a"));
161        assert_eq!(4, super::no_zwj("๐Ÿ‘ฉโ€๐Ÿš€"));
162        assert_eq!(2, super::no_zwj("๐Ÿ‘‹๐Ÿฟ"));
163        assert_eq!(8, super::no_zwj("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"));
164        assert_eq!(8, super::no_zwj("๐Ÿ‘ฉ๐Ÿผโ€๐Ÿ‘จ๐Ÿผโ€๐Ÿ‘ฆ๐Ÿผโ€๐Ÿ‘ฆ๐Ÿผ"));
165        assert_eq!(2, super::no_zwj("๏ธโค๏ธ"));
166    }
167}