Skip to main content

monitrs_core/units/
text.rs

1//! Display-width-aware truncation and padding.
2//!
3//! Every function here is bounded by a *terminal cell* budget, not a byte or
4//! `char` count, because a CJK process name occupies two cells per character
5//! and would otherwise overflow its column and corrupt the table.
6//!
7//! Truncation operates on `char` boundaries rather than grapheme clusters. This
8//! is a deliberate scope decision: a combining mark can be separated from its
9//! base character in pathological input, but no additional dependency is
10//! required and the width budget is still never exceeded.
11
12use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
13
14/// The marker appended or inserted where text was removed.
15#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
16pub enum Ellipsis {
17    /// `...` — the only form permitted in strict ASCII mode (§5.1).
18    #[default]
19    Ascii,
20    /// `…` — a single cell, available in enhanced mode.
21    Unicode,
22}
23
24impl Ellipsis {
25    /// The literal marker text.
26    #[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    /// The marker's display width in terminal cells.
35    #[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/// The display width of `text` in terminal cells.
45#[must_use]
46pub fn display_width(text: &str) -> usize {
47    UnicodeWidthStr::width(text)
48}
49
50/// Takes characters from the front of `text` while the total width stays within
51/// `budget`, returning the prefix and the width it occupies.
52fn 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
66/// Takes characters from the back of `text` while the total width stays within
67/// `budget`, returning the suffix and the width it occupies.
68fn 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/// Truncates from the tail, keeping the beginning: `rustc-driver-abc` -> `rustc-d...`.
83///
84/// Used for executables and process names, where the distinguishing information
85/// is at the front (§5.4).
86#[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    // Not enough room for content plus a marker: emit a clipped marker so the
95    // caller still sees that text was removed.
96    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/// Truncates from the middle, keeping both ends:
108/// `/opt/build/monitrs/target/debug/monitrs` -> `/opt/buil.../monitrs`.
109///
110/// Used for full command lines and paths, where the leading directory and the
111/// trailing file or argument both carry information (§5.4).
112#[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    // Bias the extra cell to the head, which usually holds the executable.
126    let tail_budget = content / 2;
127    let head_budget = content - tail_budget;
128
129    let (head, head_width) = take_prefix(text, head_budget);
130    // Reclaim any cell the head could not use (e.g. a double-width character
131    // that did not fit) so the result still fills its column.
132    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    // A pathological mix of widths can still overshoot by a cell; clamp.
139    if display_width(&out) > max_width {
140        return truncate_tail(text, max_width, ellipsis);
141    }
142    out
143}
144
145/// Pads `text` on the left to `width` cells, for right-aligned numeric columns.
146///
147/// §5.4 requires all numeric columns to be right-aligned. Over-wide input is
148/// tail-truncated rather than allowed to break the column.
149#[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/// Pads `text` on the right to `width` cells, for left-aligned text columns.
162#[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        // Each CJK character occupies two cells.
224        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        // Budget 8 = 3 for "..." + 5 content, but CJK cells come in pairs.
239        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}