diff --git a/crates/core/src/io/wrap.rs b/crates/core/src/io/wrap.rs index f42ef7c..e4ad23a 100644 --- a/crates/core/src/io/wrap.rs +++ b/crates/core/src/io/wrap.rs @@ -3,9 +3,14 @@ /// to avoid pathological cases on oversized lines. pub const MAX_WRAP_INPUT_LEN: usize = 10 * 1024 * 1024; +/// Column spacing for tab stop alignment. +const TAB_WIDTH: usize = 4; + /// Split a line into chunks of exactly `width` display columns. /// For a log viewer, we want character-level wrapping, not word-level. /// Uses `unicode-width` for correct CJK/emoji/zero-width handling. +/// Tab characters expand to the next tab-stop boundary and split across +/// rows when the expansion exceeds the remaining width. pub fn wrap_line_chars(line: &str, width: usize) -> Vec { use unicode_width::UnicodeWidthChar; @@ -19,29 +24,40 @@ pub fn wrap_line_chars(line: &str, width: usize) -> Vec { let mut row = String::new(); let mut col = 0; for ch in line.chars() { - let w = if ch == '\t' { - 4 - } else if ch.is_control() { - // Control characters (except tab): width 0, still pushed to preserve content. - // Visible rendering is the caller's responsibility. - 0 - } else { - ch.width().unwrap_or(0) - }; - if col + w > width && !row.is_empty() { - result.push(std::mem::take(&mut row)); - col = 0; - } if ch == '\t' { - row.push_str(" "); - col += 4; + let tab_stop = TAB_WIDTH - (col % TAB_WIDTH); + let mut remaining = tab_stop; + while remaining > 0 { + let avail = width.saturating_sub(col); + let fill = remaining.min(avail); + for _ in 0..fill { + row.push(' '); + } + col += fill; + remaining -= fill; + if col >= width { + result.push(std::mem::take(&mut row)); + col = 0; + } + } } else { + let w = if ch.is_control() { + // Control characters (except tab): width 0, still pushed to preserve content. + // Visible rendering is the caller's responsibility. + 0 + } else { + ch.width().unwrap_or(0) + }; + if col + w > width && !row.is_empty() { + result.push(std::mem::take(&mut row)); + col = 0; + } row.push(ch); col += w; - } - if col >= width { - result.push(std::mem::take(&mut row)); - col = 0; + if col >= width { + result.push(std::mem::take(&mut row)); + col = 0; + } } } if !row.is_empty() { @@ -108,7 +124,7 @@ mod tests { #[test] fn test_wrap_with_tab() { let result = wrap_line_chars("a\tb", 4); - assert_eq!(result, vec!["a", " ", "b"]); + assert_eq!(result, vec!["a ", "b"]); } #[test] @@ -188,4 +204,31 @@ mod tests { let result = wrap_line_chars("你好", 1); assert_eq!(result, vec!["你", "好"]); } + + #[test] + fn test_tab_narrow_width() { + let result = wrap_line_chars("\t", 2); + assert_eq!(result, vec![" ", " "]); + let result = wrap_line_chars("\t", 1); + assert_eq!(result, vec![" ", " ", " ", " "]); + } + + #[test] + fn test_tab_stop_alignment() { + assert_eq!(wrap_line_chars("a\tb", 8), vec!["a b"]); + assert_eq!(wrap_line_chars("ab\t", 4), vec!["ab "]); + assert_eq!(wrap_line_chars("abc\tb", 8), vec!["abc b"]); + } + + #[test] + fn test_tab_at_line_boundary() { + let result = wrap_line_chars("a\tb", 4); + assert_eq!(result, vec!["a ", "b"]); + } + + #[test] + fn test_tab_regression_ab_tab() { + let result = wrap_line_chars("ab\t", 4); + assert_eq!(result, vec!["ab "]); + } }