Expand tabs when rendering TUI diffs (#32461)

## What changed

- Replace tab characters in rendered diff spans with four spaces while keeping
  their wrapping width and style intact.
- Ensure diff buffers never contain literal tab characters.

## Testing

- Cover tab expansion and wrapping in `wrap_styled_spans`.
- Update diff gallery snapshots at multiple terminal sizes.

GitOrigin-RevId: 2159e84991b844ea8c4c57dfad52bc3fe7a9059f
This commit is contained in:
Felipe Coury
2026-07-11 17:47:17 +00:00
committed by copyberry
parent bbdf3030de
commit 385c0a9351
4 changed files with 41 additions and 24 deletions
+26 -9
View File
@@ -48,8 +48,10 @@ use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
use unicode_width::UnicodeWidthChar;
/// Replacement for a tab character in rendered diff content.
const TAB_REPLACEMENT: &str = " ";
/// Display width of a tab character in columns.
const TAB_WIDTH: usize = 4;
const TAB_WIDTH: usize = TAB_REPLACEMENT.len();
// -- Diff background palette --------------------------------------------------
//
@@ -989,7 +991,10 @@ fn wrap_styled_spans(spans: &[RtSpan<'static>], max_cols: usize) -> Vec<Vec<RtSp
break;
};
let ch_len = ch.len_utf8();
current_line.push(RtSpan::styled(remaining[..ch_len].to_string(), style));
current_line.push(RtSpan::styled(
remaining[..ch_len].replace('\t', TAB_REPLACEMENT),
style,
));
// Use fallback width 1 (not 0) so this branch always advances
// even if `ch` has unknown/zero display width.
col = ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 1 });
@@ -998,7 +1003,7 @@ fn wrap_styled_spans(spans: &[RtSpan<'static>], max_cols: usize) -> Vec<Vec<RtSp
}
let (chunk, rest) = remaining.split_at(byte_end);
current_line.push(RtSpan::styled(chunk.to_string(), style));
current_line.push(RtSpan::styled(chunk.replace('\t', TAB_REPLACEMENT), style));
col += chars_col;
remaining = rest;
@@ -1370,6 +1375,15 @@ mod tests {
.render_ref(f.area(), f.buffer_mut())
})
.expect("draw");
assert!(
terminal
.backend()
.buffer()
.content()
.iter()
.all(|cell| !cell.symbol().contains('\t')),
"diff buffer should not contain literal tabs"
);
assert_snapshot!(name, terminal.backend());
}
@@ -2351,12 +2365,15 @@ mod tests {
fn wrap_styled_spans_tabs_have_visible_width() {
// A tab should count as TAB_WIDTH columns, not zero.
// With max_cols=8, a tab (4 cols) + "abcde" (5 cols) = 9 cols → must wrap.
let spans = vec![RtSpan::raw("\tabcde")];
let style = Style::default().fg(Color::Green);
let spans = vec![RtSpan::styled("\tabcde", style)];
let result = wrap_styled_spans(&spans, /*max_cols*/ 8);
assert!(
result.len() >= 2,
"tab + 5 chars should exceed 8 cols and wrap, got {} line(s): {result:?}",
result.len()
assert_eq!(
result,
vec![
vec![RtSpan::styled(" abcd", style)],
vec![RtSpan::styled("e", style)],
]
);
}
@@ -2373,7 +2390,7 @@ mod tests {
.collect::<String>()
})
.collect();
assert_eq!(line_text, vec!["abcd", "\t", "界"]);
assert_eq!(line_text, vec!["abcd", " ", "界"]);
let line_width = |line: &[RtSpan<'static>]| -> usize {
line.iter()
@@ -4,9 +4,9 @@ expression: terminal.backend()
---
"• Edited 6 files (+9 -9) "
" └ assets/banner.txt (+3 -0) "
" 1 +HEADER VALUE "
" 2 +rocket 🚀 " Hidden by multi-width symbols: [(15, " ")]
" 3 +city 東京 " Hidden by multi-width symbols: [(13, " "), (15, " ")]
" 1 +HEADER VALUE "
" 2 +rocket 🚀 " Hidden by multi-width symbols: [(18, " ")]
" 3 +city 東京 " Hidden by multi-width symbols: [(16, " "), (18, " ")]
" "
" └ examples/new_sample.rs (+3 -0) "
" 1 +pub fn greet(name: &str) { "
@@ -20,8 +20,8 @@ expression: terminal.backend()
" "
" └ scripts/calc.txt → scripts/calc.py (+1 -1) "
" 1 def add(a, b): "
" 2 - return a + b "
" 2 + return a + b + 42 "
" 2 - return a + b "
" 2 + return a + b + 42 "
" 3 "
" 4 print(add(1, 2)) "
" "
@@ -4,9 +4,9 @@ expression: terminal.backend()
---
"• Edited 6 files (+9 -9) "
" └ assets/banner.txt (+3 -0) "
" 1 +HEADER VALUE "
" 2 +rocket 🚀 " Hidden by multi-width symbols: [(15, " ")]
" 3 +city 東京 " Hidden by multi-width symbols: [(13, " "), (15, " ")]
" 1 +HEADER VALUE "
" 2 +rocket 🚀 " Hidden by multi-width symbols: [(18, " ")]
" 3 +city 東京 " Hidden by multi-width symbols: [(16, " "), (18, " ")]
" "
" └ examples/new_sample.rs (+3 -0) "
" 1 +pub fn greet(name: &str) { "
@@ -20,8 +20,8 @@ expression: terminal.backend()
" "
" └ scripts/calc.txt → scripts/calc.py (+1 -1) "
" 1 def add(a, b): "
" 2 - return a + b "
" 2 + return a + b + 42 "
" 2 - return a + b "
" 2 + return a + b + 42 "
" 3 "
" 4 print(add(1, 2)) "
" "
@@ -4,9 +4,9 @@ expression: terminal.backend()
---
"• Edited 6 files (+9 -9) "
" └ assets/banner.txt (+3 -0) "
" 1 +HEADER VALUE "
" 2 +rocket 🚀 " Hidden by multi-width symbols: [(15, " ")]
" 3 +city 東京 " Hidden by multi-width symbols: [(13, " "), (15, " ")]
" 1 +HEADER VALUE "
" 2 +rocket 🚀 " Hidden by multi-width symbols: [(18, " ")]
" 3 +city 東京 " Hidden by multi-width symbols: [(16, " "), (18, " ")]
" "
" └ examples/new_sample.rs (+3 -0) "
" 1 +pub fn greet(name: &str) { "
@@ -20,8 +20,8 @@ expression: terminal.backend()
" "
" └ scripts/calc.txt → scripts/calc.py (+1 -1) "
" 1 def add(a, b): "
" 2 - return a + b "
" 2 + return a + b + 42 "
" 2 - return a + b "
" 2 + return a + b + 42 "
" 3 "
" 4 print(add(1, 2)) "
" "