1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
//! Terminal cell measurement.
//!
//! Port of upstream `rich/cells.py`. Upstream ships its own width table
//! (`_cell_widths.py`); we delegate to the `unicode-width` crate, which
//! implements the same Unicode East Asian Width rules. Any observed divergence
//! on exotic codepoints is tracked in docs/DIVERGENCES.md.
use unicode_width::UnicodeWidthChar;
/// The number of terminal cells `text` occupies. Port of `cell_len`.
pub fn cell_len(text: &str) -> usize {
text.chars().map(char_cell_width).sum()
}
/// The cell width of a single character (control chars count as 0).
pub fn char_cell_width(c: char) -> usize {
UnicodeWidthChar::width(c).unwrap_or(0)
}
/// Split `text` into chunks, each at most `width` cells wide. Port of
/// `cells.chop_cells` — char-based, so 0-width combining marks stay attached to
/// their base character (no grapheme table needed). Used to fold over-long words
/// during wrapping. Matches upstream byte-for-byte, including its quirk that a
/// leading character *wider* than `width` (e.g. a 2-cell CJK char folded to
/// width 1) yields an empty leading chunk (`["", "宽", …]`).
pub fn chop_cells(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut lines: Vec<String> = Vec::new();
let mut line = String::new();
let mut size = 0usize;
for c in text.chars() {
let cw = char_cell_width(c);
// No `!line.is_empty()` guard: when the first char of a fresh line is
// already wider than `width`, upstream appends the empty prefix (and so
// do we). For normal text (`cw <= width`) this branch never fires on an
// empty line, so ordinary wrapping is unaffected.
if size + cw > width {
lines.push(std::mem::take(&mut line));
size = 0;
}
line.push(c);
size += cw;
}
if !line.is_empty() {
lines.push(line);
}
lines
}
/// Crop `text` to at most `width` cells, never padding. Unlike [`set_cell_size`]
/// this leaves shorter text unchanged. Mirrors `Text.truncate` at the cell level.
pub fn truncate(text: &str, width: usize) -> String {
if cell_len(text) <= width {
text.to_string()
} else {
set_cell_size(text, width)
}
}
/// Truncate or right-pad `text` (with spaces) so it occupies exactly `total`
/// cells. Port of `set_cell_size`.
pub fn set_cell_size(text: &str, total: usize) -> String {
let mut width = 0usize;
let mut result = String::new();
for c in text.chars() {
let cw = char_cell_width(c);
if width + cw > total {
break;
}
width += cw;
result.push(c);
}
while width < total {
result.push(' ');
width += 1;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_len() {
assert_eq!(cell_len("hello"), 5);
}
#[test]
fn wide_chars_count_double() {
assert_eq!(cell_len("宽"), 2);
}
#[test]
fn set_size_pads_and_truncates() {
assert_eq!(set_cell_size("hi", 5), "hi ");
assert_eq!(set_cell_size("hello", 3), "hel");
}
#[test]
fn chop_ascii_and_wide() {
// Matches real rich 15.0.0 `chop_cells`.
assert_eq!(chop_cells("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
// Each wide char (2 cells) gets its own chunk at width 3.
assert_eq!(chop_cells("宽宽宽宽", 3), vec!["宽", "宽", "宽", "宽"]);
}
#[test]
fn chop_char_wider_than_width_emits_empty_leading_chunk() {
// Upstream quirk: folding a 2-cell CJK char to width 1 yields an empty
// leading chunk. Captured from real rich 15.0.0 `chop_cells`:
// chop_cells("宽宽", 1) == ["", "宽", "宽"]
// chop_cells("宽", 1) == ["", "宽"]
// chop_cells("a宽b", 2) == ["a", "宽", "b"]
assert_eq!(chop_cells("宽宽", 1), vec!["", "宽", "宽"]);
assert_eq!(chop_cells("宽", 1), vec!["", "宽"]);
assert_eq!(chop_cells("a宽b", 2), vec!["a", "宽", "b"]);
}
#[test]
fn chop_keeps_combining_marks_attached() {
// base char + U+0301 (combining acute): each grapheme is one cell, the
// combining mark is 0-width, so it stays with its base — exactly as
// upstream's char-based `chop_cells` does (no grapheme table needed).
let decomposed: String = "abcdef".chars().flat_map(|c| [c, '\u{301}']).collect();
let chunks = chop_cells(&decomposed, 3);
assert_eq!(chunks.len(), 2);
assert_eq!(
chunks.iter().map(|c| cell_len(c)).collect::<Vec<_>>(),
vec![3, 3]
);
// Three base chars + three combining marks per chunk.
assert_eq!(chunks[0].chars().count(), 6);
}
}