1use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
13
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
16pub enum Ellipsis {
17 #[default]
19 Ascii,
20 Unicode,
22}
23
24impl Ellipsis {
25 #[must_use]
27 pub const fn as_str(self) -> &'static str {
28 match self {
29 Self::Ascii => "...",
30 Self::Unicode => "\u{2026}",
31 }
32 }
33
34 #[must_use]
36 pub const fn width(self) -> usize {
37 match self {
38 Self::Ascii => 3,
39 Self::Unicode => 1,
40 }
41 }
42}
43
44#[must_use]
46pub fn display_width(text: &str) -> usize {
47 UnicodeWidthStr::width(text)
48}
49
50fn take_prefix(text: &str, budget: usize) -> (&str, usize) {
53 let mut width = 0usize;
54 let mut end = 0usize;
55 for (offset, ch) in text.char_indices() {
56 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
57 if width + ch_width > budget {
58 break;
59 }
60 width += ch_width;
61 end = offset + ch.len_utf8();
62 }
63 (text.get(..end).unwrap_or(""), width)
64}
65
66fn take_suffix(text: &str, budget: usize) -> (&str, usize) {
69 let mut width = 0usize;
70 let mut start = text.len();
71 for (offset, ch) in text.char_indices().rev() {
72 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
73 if width + ch_width > budget {
74 break;
75 }
76 width += ch_width;
77 start = offset;
78 }
79 (text.get(start..).unwrap_or(""), width)
80}
81
82#[must_use]
87pub fn truncate_tail(text: &str, max_width: usize, ellipsis: Ellipsis) -> String {
88 if display_width(text) <= max_width {
89 return text.to_owned();
90 }
91 if max_width == 0 {
92 return String::new();
93 }
94 if max_width <= ellipsis.width() {
97 let (prefix, _) = take_prefix(ellipsis.as_str(), max_width);
98 return prefix.to_owned();
99 }
100 let (prefix, _) = take_prefix(text, max_width - ellipsis.width());
101 let mut out = String::with_capacity(prefix.len() + ellipsis.as_str().len());
102 out.push_str(prefix);
103 out.push_str(ellipsis.as_str());
104 out
105}
106
107#[must_use]
113pub fn truncate_middle(text: &str, max_width: usize, ellipsis: Ellipsis) -> String {
114 if display_width(text) <= max_width {
115 return text.to_owned();
116 }
117 if max_width == 0 {
118 return String::new();
119 }
120 if max_width <= ellipsis.width() {
121 let (prefix, _) = take_prefix(ellipsis.as_str(), max_width);
122 return prefix.to_owned();
123 }
124 let content = max_width - ellipsis.width();
125 let tail_budget = content / 2;
127 let head_budget = content - tail_budget;
128
129 let (head, head_width) = take_prefix(text, head_budget);
130 let (tail, _) = take_suffix(text, tail_budget + (head_budget - head_width));
133
134 let mut out = String::with_capacity(head.len() + ellipsis.as_str().len() + tail.len());
135 out.push_str(head);
136 out.push_str(ellipsis.as_str());
137 out.push_str(tail);
138 if display_width(&out) > max_width {
140 return truncate_tail(text, max_width, ellipsis);
141 }
142 out
143}
144
145#[must_use]
150pub fn pad_left(text: &str, width: usize, ellipsis: Ellipsis) -> String {
151 let text = truncate_tail(text, width, ellipsis);
152 let pad = width.saturating_sub(display_width(&text));
153 let mut out = String::with_capacity(pad + text.len());
154 for _ in 0..pad {
155 out.push(' ');
156 }
157 out.push_str(&text);
158 out
159}
160
161#[must_use]
163pub fn pad_right(text: &str, width: usize, ellipsis: Ellipsis) -> String {
164 let text = truncate_tail(text, width, ellipsis);
165 let pad = width.saturating_sub(display_width(&text));
166 let mut out = String::with_capacity(pad + text.len());
167 out.push_str(&text);
168 for _ in 0..pad {
169 out.push(' ');
170 }
171 out
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn short_text_is_returned_unchanged() {
180 assert_eq!(truncate_tail("rustc", 10, Ellipsis::Ascii), "rustc");
181 assert_eq!(truncate_middle("rustc", 10, Ellipsis::Ascii), "rustc");
182 }
183
184 #[test]
185 fn tail_truncation_keeps_the_head() {
186 assert_eq!(
187 truncate_tail("rustc-driver", 9, Ellipsis::Ascii),
188 "rustc-..."
189 );
190 assert_eq!(
191 truncate_tail("rustc-driver", 9, Ellipsis::Unicode),
192 "rustc-dr\u{2026}"
193 );
194 }
195
196 #[test]
197 fn middle_truncation_keeps_both_ends() {
198 let path = "/opt/build/monitrs/target/debug/monitrs";
199 let out = truncate_middle(path, 20, Ellipsis::Ascii);
200 assert_eq!(display_width(&out), 20, "{out:?}");
201 assert!(out.starts_with("/opt"), "{out:?}");
202 assert!(out.ends_with("monitrs"), "{out:?}");
203 assert!(out.contains("..."), "{out:?}");
204 }
205
206 #[test]
207 fn zero_width_yields_empty_and_never_panics() {
208 assert_eq!(truncate_tail("anything", 0, Ellipsis::Ascii), "");
209 assert_eq!(truncate_middle("anything", 0, Ellipsis::Ascii), "");
210 assert_eq!(pad_left("anything", 0, Ellipsis::Ascii), "");
211 }
212
213 #[test]
214 fn width_below_the_marker_still_respects_the_budget() {
215 for width in 0..=3 {
216 let out = truncate_tail("some-long-name", width, Ellipsis::Ascii);
217 assert!(display_width(&out) <= width, "width {width} gave {out:?}");
218 }
219 }
220
221 #[test]
222 fn double_width_characters_never_overflow_the_budget() {
223 let name = "日本語のプロセス名";
225 for width in 0..=20 {
226 let tail = truncate_tail(name, width, Ellipsis::Ascii);
227 let middle = truncate_middle(name, width, Ellipsis::Ascii);
228 assert!(display_width(&tail) <= width, "tail {width}: {tail:?}");
229 assert!(
230 display_width(&middle) <= width,
231 "middle {width}: {middle:?}"
232 );
233 }
234 }
235
236 #[test]
237 fn an_odd_budget_with_double_width_text_is_filled_not_wasted() {
238 let out = truncate_middle("日本語のプロセス名", 8, Ellipsis::Ascii);
240 assert!(display_width(&out) <= 8, "{out:?}");
241 }
242
243 #[test]
244 fn padding_right_aligns_numeric_columns() {
245 assert_eq!(pad_left("287%", 6, Ellipsis::Ascii), " 287%");
246 assert_eq!(pad_right("rustc", 8, Ellipsis::Ascii), "rustc ");
247 assert_eq!(display_width(&pad_left("日本", 6, Ellipsis::Ascii)), 6);
248 }
249
250 #[test]
251 fn padding_truncates_rather_than_breaking_the_column() {
252 assert_eq!(
253 display_width(&pad_left("1234567890", 5, Ellipsis::Ascii)),
254 5
255 );
256 }
257}