Skip to main content

rusty_rich/
cells.rs

1//! Unicode cell width handling — equivalent to Rich's `cells.py`.
2//!
3//! Provides correct cell-width measurement for CJK, emoji, and ZWJ sequences.
4
5// ---------------------------------------------------------------------------
6// cell_len — total Unicode display width of a string
7// ---------------------------------------------------------------------------
8
9/// Return the total cell width of `text`, handling double-width characters
10/// and zero-width characters correctly.
11pub fn cell_len(text: &str) -> usize {
12    unicode_width::UnicodeWidthStr::width(text)
13}
14
15// ---------------------------------------------------------------------------
16// get_character_cell_size — width of a single character
17// ---------------------------------------------------------------------------
18
19/// Return the cell width of a single character (0, 1, or 2).
20pub fn get_character_cell_size(ch: char) -> usize {
21    unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0)
22}
23
24// ---------------------------------------------------------------------------
25// set_cell_size — pad or crop text to a target cell width
26// ---------------------------------------------------------------------------
27
28/// Pad or crop `text` so that its total cell width equals `target_width`.
29/// If the text is too short, right-pad with spaces.
30/// If it's too long, it is cropped (if a double-width character is split,
31/// it is replaced with spaces).
32pub fn set_cell_size(text: &str, target_width: usize) -> String {
33    let current = cell_len(text);
34    if current == target_width {
35        return text.to_string();
36    }
37    if current > target_width {
38        // Crop to target
39        let mut out = String::new();
40        let mut w = 0usize;
41        for ch in text.chars() {
42            let cw = get_character_cell_size(ch);
43            if w + cw > target_width {
44                // Fill remaining with spaces
45                out.push_str(&" ".repeat(target_width - w));
46                return out;
47            }
48            out.push(ch);
49            w += cw;
50        }
51        out
52    } else {
53        // Pad with spaces
54        format!("{}{}", text, " ".repeat(target_width - current))
55    }
56}
57
58// ---------------------------------------------------------------------------
59// chop_cells — break text into lines that fit within width cells
60// ---------------------------------------------------------------------------
61
62/// Break `text` into a list of strings, each of which fits within `width`
63/// cells. Double-width characters are not split; if a character would span
64/// the boundary, it starts the next line.
65pub fn chop_cells(text: &str, width: usize) -> Vec<String> {
66    if width == 0 {
67        return vec![text.to_string()];
68    }
69
70    let mut lines: Vec<String> = Vec::new();
71    let mut current = String::new();
72    let mut current_w = 0usize;
73
74    for ch in text.chars() {
75        let cw = get_character_cell_size(ch);
76
77        if ch == '\n' {
78            lines.push(current);
79            current = String::new();
80            current_w = 0;
81            continue;
82        }
83
84        if current_w + cw > width {
85            lines.push(current);
86            current = String::new();
87            current_w = 0;
88        }
89
90        current.push(ch);
91        current_w += cw;
92    }
93
94    if !current.is_empty() {
95        lines.push(current);
96    }
97
98    // If all lines are empty (text was empty), return one empty line
99    if lines.is_empty() {
100        lines.push(String::new());
101    }
102
103    lines
104}
105
106// ---------------------------------------------------------------------------
107// split_text — split text at a given cell offset
108// ---------------------------------------------------------------------------
109
110/// Split text at the given cell offset, returning `(left, right)`.
111/// If the offset falls in the middle of a double-width character, that
112/// character is replaced with spaces in the left part and starts the right.
113pub fn split_text(text: &str, cell_offset: usize) -> (String, String) {
114    let mut left = String::new();
115    let mut right = String::new();
116    let mut w = 0usize;
117    let mut passed = false;
118
119    for ch in text.chars() {
120        let cw = get_character_cell_size(ch);
121
122        if passed {
123            right.push(ch);
124        } else if w + cw > cell_offset {
125            // This character straddles the boundary
126            // Fill remaining cell space on left with spaces
127            left.push_str(&" ".repeat(cell_offset - w));
128            right.push(ch);
129            passed = true;
130        } else {
131            left.push(ch);
132            w += cw;
133            if w == cell_offset {
134                passed = true;
135            }
136        }
137    }
138
139    (left, right)
140}
141
142// ---------------------------------------------------------------------------
143// Tests
144// ---------------------------------------------------------------------------
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_cell_len_ascii() {
152        assert_eq!(cell_len("hello"), 5);
153    }
154
155    #[test]
156    fn test_cell_len_cjk() {
157        assert_eq!(cell_len("你好"), 4); // 2 wide chars × 2 = 4
158    }
159
160    #[test]
161    fn test_cell_len_emoji() {
162        assert_eq!(cell_len("🎉"), 2);
163    }
164
165    #[test]
166    fn test_set_cell_size_pad() {
167        assert_eq!(set_cell_size("hi", 10), "hi        ");
168    }
169
170    #[test]
171    fn test_set_cell_size_crop() {
172        assert_eq!(set_cell_size("hello world", 5), "hello");
173    }
174
175    #[test]
176    fn test_set_cell_size_exact() {
177        assert_eq!(set_cell_size("hello", 5), "hello");
178    }
179
180    #[test]
181    fn test_chop_cells_basic() {
182        let lines = chop_cells("hello world", 5);
183        assert_eq!(lines, vec!["hello", " worl", "d"]);
184    }
185
186    #[test]
187    fn test_chop_cells_newline() {
188        let lines = chop_cells("a\nb\nc", 10);
189        assert_eq!(lines, vec!["a", "b", "c"]);
190    }
191
192    #[test]
193    fn test_split_text_middle() {
194        let (left, right) = split_text("hello world", 5);
195        assert_eq!(left, "hello");
196        assert_eq!(right, " world");
197    }
198
199    #[test]
200    fn test_split_text_past_end() {
201        let (left, right) = split_text("hi", 10);
202        assert_eq!(left, "hi");
203        assert_eq!(right, "");
204    }
205}