Skip to main content

atomcode_tuix/
width.rs

1// crates/atomcode-tuix/src/width.rs
2use unicode_width::UnicodeWidthChar;
3
4/// Terminal column width of a string, CJK-aware.
5pub fn display_width(s: &str) -> usize {
6    s.chars()
7        .map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
8        .sum()
9}
10
11/// Split a line (possibly containing SGR escape sequences) into chunks
12/// whose visible display width is at most `max_cols`. SGR bytes pass
13/// through without consuming display columns. Handles CJK/emoji width.
14///
15/// This is the renderer-side replacement for terminal autowrap: we cannot
16/// trust the terminal to wrap consistently at scroll-region boundaries,
17/// so we wrap ourselves before emitting.
18pub fn wrap_line_to_width(line: &str, max_cols: usize) -> Vec<String> {
19    if max_cols == 0 || line.is_empty() {
20        return vec![line.to_string()];
21    }
22    let mut chunks: Vec<String> = Vec::new();
23    let mut current = String::new();
24    let mut cur_width = 0usize;
25    let mut chars = line.chars().peekable();
26
27    while let Some(c) = chars.next() {
28        if c == '\x1b' {
29            // SGR passthrough — doesn't count toward display width.
30            current.push(c);
31            while let Some(&p) = chars.peek() {
32                chars.next();
33                current.push(p);
34                if p.is_ascii_alphabetic() || p == '~' {
35                    break;
36                }
37            }
38            continue;
39        }
40        let w = UnicodeWidthChar::width(c).unwrap_or(0);
41        if cur_width + w > max_cols && !current.is_empty() {
42            chunks.push(std::mem::take(&mut current));
43            cur_width = 0;
44        }
45        current.push(c);
46        cur_width += w;
47    }
48    if !current.is_empty() {
49        chunks.push(current);
50    }
51    if chunks.is_empty() {
52        chunks.push(String::new());
53    }
54    chunks
55}
56
57/// Wrap `text` to `max_cols` columns AND locate the cursor's 2D position
58/// within the wrapped layout. Honours explicit `\n` as a hard line break
59/// (Shift+Enter in the input buffer). Returns `(lines, cursor_row, cursor_col)`
60/// where `cursor_row` is 0-based within `lines` and `cursor_col` is the
61/// display column within that row.
62///
63/// `cursor_byte` is a byte offset into `text`; `text.len()` (end-of-buffer)
64/// is the expected maximum.
65pub fn wrap_with_cursor(
66    text: &str,
67    max_cols: usize,
68    cursor_byte: usize,
69) -> (Vec<String>, usize, usize) {
70    if max_cols == 0 {
71        return (vec![String::new()], 0, 0);
72    }
73    let mut lines: Vec<String> = vec![String::new()];
74    let mut col = 0usize;
75    let mut byte = 0usize;
76    let mut cursor_row = 0usize;
77    let mut cursor_col = 0usize;
78    let mut cursor_set = false;
79
80    for c in text.chars() {
81        // Wrap check BEFORE writing the char, so a cursor that lands
82        // at byte==boundary appears on the new row at col 0 rather
83        // than pinned to col `max_cols` on the old row (which would
84        // overlap the right border).
85        if c != '\n' {
86            let w = UnicodeWidthChar::width(c).unwrap_or(0);
87            if col + w > max_cols && !lines.last().unwrap().is_empty() {
88                lines.push(String::new());
89                col = 0;
90            }
91        }
92        if !cursor_set && byte == cursor_byte {
93            cursor_row = lines.len() - 1;
94            cursor_col = col;
95            cursor_set = true;
96        }
97        if c == '\n' {
98            lines.push(String::new());
99            col = 0;
100        } else {
101            let w = UnicodeWidthChar::width(c).unwrap_or(0);
102            lines.last_mut().unwrap().push(c);
103            col += w;
104        }
105        byte += c.len_utf8();
106    }
107
108    // Cursor at end-of-buffer falls through.
109    if !cursor_set {
110        cursor_row = lines.len() - 1;
111        cursor_col = col;
112    }
113    (lines, cursor_row, cursor_col)
114}
115
116/// Slice `s` starting at display column `start_col`, taking up to `max_cols`
117/// columns. Characters that straddle the start boundary are skipped. Used to
118/// implement horizontal scroll in the input prompt — keeps the cursor visible
119/// when the buffer exceeds the viewport width.
120pub fn slice_cols(s: &str, start_col: usize, max_cols: usize) -> String {
121    let mut col = 0usize;
122    let mut acc = String::new();
123    let mut acc_w = 0usize;
124    for c in s.chars() {
125        let w = UnicodeWidthChar::width(c).unwrap_or(0);
126        if col + w <= start_col {
127            col += w;
128        } else if col < start_col {
129            col += w;
130        } else {
131            if acc_w + w > max_cols {
132                break;
133            }
134            acc.push(c);
135            acc_w += w;
136            col += w;
137        }
138    }
139    acc
140}
141
142/// Truncate `s` so its display width is at most `max_cols`.
143/// Guaranteed to return a valid UTF-8 string that never splits a grapheme.
144pub fn truncate_to_width(s: &str, max_cols: usize) -> String {
145    if max_cols == 0 {
146        return String::new();
147    }
148    let mut acc = String::with_capacity(s.len());
149    let mut cols = 0usize;
150    for c in s.chars() {
151        let w = UnicodeWidthChar::width(c).unwrap_or(0);
152        if cols + w > max_cols {
153            break;
154        }
155        acc.push(c);
156        cols += w;
157    }
158    acc
159}
160
161/// Truncate `s` to `max_cols` display columns, appending `…` when
162/// truncation happened so the reader sees a visible "there was more"
163/// marker instead of a silent cut mid-word. Reserves 1 column for the
164/// ellipsis, so the actual content slice is `max_cols - 1` cols wide.
165/// Strings that already fit are returned unchanged.
166pub fn truncate_with_ellipsis(s: &str, max_cols: usize) -> String {
167    if max_cols == 0 {
168        return String::new();
169    }
170    if display_width(s) <= max_cols {
171        return s.to_string();
172    }
173    let budget = max_cols.saturating_sub(1).max(1);
174    let mut acc = truncate_to_width(s, budget);
175    acc.push('…');
176    acc
177}
178
179/// Truncate a file-system path to `max_cols` display columns, using a
180/// path-aware strategy that preserves the **last segment** (the project or
181/// folder name — the most useful bit) and replaces leading segments with
182/// `.../`.  Both `/` and `\` are treated as separators.
183///
184/// Examples (max_cols = 20):
185///
186///   ~/Documents/WPSDrive/NotLoginPage
187///     → .../NotLoginPage          (keeps the last segment)
188///
189///   ~/a/b/c                       (max_cols = 6)
190///     → .../c                     (keeps `.../` + last segment)
191///
192///   ~/foo                         (max_cols = 5)
193///     → ~/foo                     (fits, no truncation)
194///
195/// If the last segment alone exceeds `max_cols`, the function falls back
196/// to a plain `truncate_with_ellipsis` so the output always fits.
197pub fn truncate_path(path: &str, max_cols: usize) -> String {
198    if max_cols == 0 {
199        return String::new();
200    }
201    if display_width(path) <= max_cols {
202        return path.to_string();
203    }
204
205    // Find the last separator and take everything after it.
206    let last_sep = path.rfind(|c: char| c == '/' || c == '\\');
207    let last_segment = match last_sep {
208        Some(i) => &path[i + 1..],
209        None => path, // no separator — the whole string is the "segment"
210    };
211
212    // Build the candidate: ".../" + last_segment
213    let ellipsis_prefix = ".../";
214    let candidate = format!("{}{}", ellipsis_prefix, last_segment);
215
216    if display_width(&candidate) <= max_cols {
217        return candidate;
218    }
219
220    // Last segment is too long even with ".../" prefix — truncate it.
221    // Reserve width for ".../" (4 cols).
222    let prefix_w = display_width(ellipsis_prefix);
223    let budget = max_cols.saturating_sub(prefix_w).max(1);
224    let truncated_last = truncate_to_width(last_segment, budget);
225    format!("{}{}", ellipsis_prefix, truncated_last)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn ascii_width_equals_len() {
234        assert_eq!(display_width("hello"), 5);
235    }
236
237    #[test]
238    fn cjk_char_is_width_two() {
239        assert_eq!(display_width("你好"), 4);
240        assert_eq!(display_width("a你b"), 4); // 1 + 2 + 1
241    }
242
243    #[test]
244    fn emoji_width_is_two() {
245        assert_eq!(display_width("👍"), 2);
246    }
247
248    #[test]
249    fn truncate_to_width_respects_boundary() {
250        // 15-char ASCII input, limit width 5 → first 5 chars
251        assert_eq!(truncate_to_width("hello world", 5), "hello");
252    }
253
254    #[test]
255    fn truncate_to_width_cjk_never_splits_char() {
256        // "你好world" = 2+2+1+1+1+1+1 = 9 cols; limit 3 → "你" (width 2), not "你\xXX"
257        let out = truncate_to_width("你好world", 3);
258        assert_eq!(out, "你");
259        assert_eq!(display_width(&out), 2);
260    }
261
262    #[test]
263    fn truncate_to_width_zero_width_safe() {
264        assert_eq!(truncate_to_width("abc", 0), "");
265    }
266
267    #[test]
268    fn truncate_to_width_exact_fit() {
269        assert_eq!(truncate_to_width("你好", 4), "你好");
270    }
271
272    #[test]
273    fn truncate_to_width_preserves_under_limit() {
274        assert_eq!(truncate_to_width("hi", 10), "hi");
275    }
276
277    #[test]
278    fn slice_cols_window_midway() {
279        // "abcdefghij" start 3, width 4 → "defg"
280        assert_eq!(slice_cols("abcdefghij", 3, 4), "defg");
281    }
282
283    #[test]
284    fn slice_cols_cjk_straddle_skipped() {
285        // "你好world" = 2+2+1+1+1+1+1. start_col=1 straddles "你" → skip it.
286        // Then start at col 2 with 4 cols → "好wo".
287        assert_eq!(slice_cols("你好world", 1, 4), "好wo");
288    }
289
290    #[test]
291    fn slice_cols_past_end_empty() {
292        assert_eq!(slice_cols("abc", 10, 5), "");
293    }
294
295    #[test]
296    fn slice_cols_start_zero_matches_truncate() {
297        assert_eq!(slice_cols("hello world", 0, 5), "hello");
298    }
299
300    #[test]
301    fn wrap_with_cursor_short_text_single_row() {
302        let (lines, r, c) = wrap_with_cursor("hi", 10, 2);
303        assert_eq!(lines, vec!["hi".to_string()]);
304        assert_eq!((r, c), (0, 2));
305    }
306
307    #[test]
308    fn wrap_with_cursor_overflow_moves_to_next_row() {
309        let (lines, r, c) = wrap_with_cursor("abcdef", 3, 3);
310        assert_eq!(lines, vec!["abc".to_string(), "def".to_string()]);
311        // cursor at byte 3 (between abc and def) → start of row 1
312        assert_eq!((r, c), (1, 0));
313    }
314
315    #[test]
316    fn wrap_with_cursor_honours_explicit_newline() {
317        let (lines, r, c) = wrap_with_cursor("ab\ncd", 10, 4);
318        assert_eq!(lines, vec!["ab".to_string(), "cd".to_string()]);
319        assert_eq!((r, c), (1, 1));
320    }
321
322    #[test]
323    fn wrap_with_cursor_end_of_buffer() {
324        let (lines, r, c) = wrap_with_cursor("hello", 10, 5);
325        assert_eq!(lines, vec!["hello".to_string()]);
326        assert_eq!((r, c), (0, 5));
327    }
328
329    #[test]
330    fn wrap_with_cursor_cjk_widths() {
331        // "你好" = 4 cols. max=3 → wraps after "你" (width 2 fits, next
332        // char 好 (w=2) would overflow 2+2=4>3, so wrap).
333        let (lines, _, _) = wrap_with_cursor("你好", 3, 0);
334        assert_eq!(lines, vec!["你".to_string(), "好".to_string()]);
335    }
336
337    // --- truncate_path tests ---
338
339    #[test]
340    fn truncate_path_short_path_unchanged() {
341        // Path fits within max_cols → returned as-is.
342        assert_eq!(truncate_path("~/foo", 20), "~/foo");
343    }
344
345    #[test]
346    fn truncate_path_keeps_last_segment() {
347        // Long path: keep last segment with ".../" prefix.
348        assert_eq!(
349            truncate_path("~/Documents/WPSDrive/NotLoginPage", 20),
350            ".../NotLoginPage"
351        );
352    }
353
354    #[test]
355    fn truncate_path_exact_fit() {
356        // ".../NotLoginPage" = 16 cols. At max_cols = 16 it should fit.
357        assert_eq!(
358            truncate_path("~/Documents/WPSDrive/NotLoginPage", 16),
359            ".../NotLoginPage"
360        );
361    }
362
363    #[test]
364    fn truncate_path_very_tight_budget() {
365        // Even a single-char last segment + ".../" = 5 cols should fit.
366        assert_eq!(truncate_path("~/a/b/c", 6), ".../c");
367    }
368
369    #[test]
370    fn truncate_path_last_segment_too_long() {
371        // Last segment itself exceeds budget after ".../" prefix.
372        // ".../" = 4 cols, budget for last segment = 10 - 4 = 6 cols.
373        // "NotLoginPage" = 12 cols → truncated to 6 cols.
374        assert_eq!(
375            truncate_path("~/Documents/WPSDrive/NotLoginPage", 10),
376            ".../NotLog"
377        );
378    }
379
380    #[test]
381    fn truncate_path_no_separator() {
382        // No path separators → treat entire string as the "last segment".
383        // "verylongname" = 12 cols, max 8 → ".../" + 4 cols of name.
384        assert_eq!(truncate_path("verylongname", 8), ".../very");
385    }
386
387    #[test]
388    fn truncate_path_windows_backslash() {
389        // Windows paths with backslash separators.
390        assert_eq!(
391            truncate_path(r"~\Documents\WPSDrive\NotLoginPage", 20),
392            ".../NotLoginPage"
393        );
394    }
395
396    #[test]
397    fn truncate_path_zero_cols() {
398        assert_eq!(truncate_path("~/foo", 0), "");
399    }
400
401    #[test]
402    fn truncate_path_cjk_segment() {
403        // CJK project name: "项目" = 4 cols, ".../项目" = 8 cols.
404        assert_eq!(
405            truncate_path("~/Documents/工作/项目", 20),
406            ".../项目"
407        );
408    }
409
410    #[test]
411    fn truncate_path_cjk_tight_budget() {
412        // "项目" = 4 cols, ".../" = 4 cols, total = 8.
413        assert_eq!(truncate_path("~/a/b/项目", 8), ".../项目");
414    }
415    #[test]
416    fn wrap_line_to_width_truecolor_sgr_passthrough_zero_width() {
417        // Truecolor open `\x1b[38;2;198;120;221m` is 18 bytes of escape sequence.
418        // If the SGR-passthrough loop ever stops handling it correctly, those
419        // bytes leak into column accounting and downstream wrapping shatters.
420        // Pin the invariant: the visible content `let x = 1;` is 10 cols, so
421        // it must fit in a 10-col budget with no wrap.
422        let tinted = "\x1b[38;2;198;120;221mlet\x1b[23;39m x = 1;";
423        let chunks = wrap_line_to_width(tinted, 10);
424        assert_eq!(chunks.len(), 1, "must not wrap when visible width fits, got: {:?}", chunks);
425        // The tinted line is returned verbatim — escapes still present.
426        assert!(chunks[0].contains("\x1b[38;2;198;120;221m"));
427    }
428
429    #[test]
430    fn wrap_line_to_width_truecolor_with_italic_passthrough() {
431        // `\x1b[3;38;2;124;132;153m` is the COMMENT SGR — 3 (italic) plus
432        // truecolor fg. Same passthrough guarantee.
433        let tinted = "\x1b[3;38;2;124;132;153m// comment\x1b[23;39m";
434        let chunks = wrap_line_to_width(tinted, 10);
435        assert_eq!(chunks.len(), 1);
436    }
437}