Skip to content

Commit d927425

Browse files
jgarzikclaude
andcommitted
vi: Fix panic on UTF-8 char boundary in narrow terminal
Use char-based truncation instead of byte slicing when displaying lines and messages. Removes redundant truncation in editor.rs since expand_line already caps at max_cols. Fixes #536 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 87444df commit d927425

File tree

4 files changed

+59
-15
lines changed

4 files changed

+59
-15
lines changed

editors/tests/pty/mod.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,50 @@ fn test_pty_vi_delete_and_save() {
225225
assert_eq!(lines[0], "Line1");
226226
assert_eq!(lines[1], "Line3");
227227
}
228+
229+
/// Test: UTF-8 display with multibyte characters in narrow terminal.
230+
/// Regression test for issue #536 - vi panics on UTF-8 char boundary.
231+
#[test]
232+
fn test_pty_vi_utf8_display() {
233+
let td = tempdir().unwrap();
234+
let file_path = td.path().join("test_utf8.txt");
235+
// Cyrillic text "Привет мир" = "Hello world" - each Cyrillic char is 2 bytes
236+
std::fs::write(&file_path, "Привет мир\n").unwrap();
237+
238+
let pty_system = native_pty_system();
239+
let pair = pty_system
240+
.openpty(PtySize {
241+
rows: 10,
242+
cols: 20, // Narrow terminal to force truncation
243+
pixel_width: 0,
244+
pixel_height: 0,
245+
})
246+
.unwrap();
247+
248+
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_vi"));
249+
cmd.arg(&file_path);
250+
cmd.env("TERM", "vt100");
251+
252+
let mut child = pair.slave.spawn_command(cmd).unwrap();
253+
drop(pair.slave);
254+
255+
let reader = pair.master.try_clone_reader().unwrap();
256+
let _reader_thread = spawn_reader_drain(reader);
257+
let mut writer = pair.master.take_writer().unwrap();
258+
259+
// Wait for vi startup
260+
thread::sleep(Duration::from_millis(500));
261+
262+
// Move cursor right a few times (exercises display with UTF-8)
263+
write_keys(&mut writer, "lll");
264+
thread::sleep(Duration::from_millis(100));
265+
266+
// Quit without saving
267+
write_keys(&mut writer, ":q!\r");
268+
269+
wait_with_timeout(&mut child, Duration::from_secs(5));
270+
271+
// If we got here without panic, the test passed
272+
let contents = std::fs::read_to_string(&file_path).unwrap();
273+
assert_eq!(contents, "Привет мир\n");
274+
}

editors/vi/editor.rs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3032,14 +3032,9 @@ impl Editor {
30323032

30333033
if line_num <= self.buffer.line_count() {
30343034
if let Some(line) = self.buffer.line(line_num) {
3035-
// Expand tabs and truncate
3035+
// Expand tabs and truncate (expand_line already caps at max_cols)
30363036
let content = self.screen.expand_line(line.content(), size.cols as usize);
3037-
let display = if content.len() > size.cols as usize {
3038-
&content[..size.cols as usize]
3039-
} else {
3040-
&content
3041-
};
3042-
self.terminal.write_str(display)?;
3037+
self.terminal.write_str(&content)?;
30433038
}
30443039
} else {
30453040
self.terminal.write_str("~")?;

editors/vi/ui/screen.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Screen buffer for efficient display updates.
22
3+
use super::display::truncate_to_width;
34
use super::terminal::{Terminal, TerminalSize};
45
use crate::buffer::Buffer;
56
use crate::error::Result;
@@ -103,6 +104,11 @@ impl Screen {
103104
}
104105
}
105106

107+
/// Get tab stop width.
108+
pub fn tabstop(&self) -> usize {
109+
self.tabstop
110+
}
111+
106112
/// Mark all rows as dirty.
107113
pub fn mark_all_dirty(&mut self) {
108114
for row in &mut self.rows {
@@ -229,13 +235,10 @@ impl Screen {
229235
output.push_str(&format!("\x1b[{};1H", status_row));
230236
output.push_str("\x1b[K");
231237
if !self.message.is_empty() {
232-
// Truncate message to fit
238+
// Truncate message to fit (using UTF-8 safe truncation)
233239
let max_len = self.size.cols as usize;
234-
if self.message.len() > max_len {
235-
output.push_str(&self.message[..max_len]);
236-
} else {
237-
output.push_str(&self.message);
238-
}
240+
let msg = truncate_to_width(&self.message, max_len, self.tabstop);
241+
output.push_str(&msg);
239242
}
240243

241244
// Position cursor

pax/options.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -540,8 +540,7 @@ pub fn format_list_entry(format: &str, info: &ListEntryInfo) -> String {
540540
if c == '%' {
541541
match chars.next() {
542542
Some(spec) => {
543-
if let Some((_, handler)) =
544-
FORMAT_SPECIFIERS.iter().find(|(ch, _)| *ch == spec)
543+
if let Some((_, handler)) = FORMAT_SPECIFIERS.iter().find(|(ch, _)| *ch == spec)
545544
{
546545
result.push_str(&handler(info));
547546
} else {

0 commit comments

Comments
 (0)