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}