From b7a8bf49c4d1c26d172e812faf63f96fdfa3961c Mon Sep 17 00:00:00 2001 From: zhangheng Date: Thu, 25 Jun 2026 12:37:11 +0800 Subject: [PATCH] fix(terminal): use grapheme_width for accurate column slicing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit text_in_cols and text_slice_cols now walk grapheme clusters with grapheme_width() instead of assuming 1 char = 1 column. This fixes column slicing for: - Wide characters (CJK, emoji) that occupy 2 grid columns - Combining marks that occupy 0 columns The prior 1:1 char∶col mapping caused selection-copy and serialize output to misalign column ranges when wide/combining characters were present in a row. - Update roadmap: mark both P0 follow-up items as resolved --- TERMINAL_ROADMAP.md | 2 +- app/src/terminal/core.rs | 39 +++++++++++++++++++++++++++++---------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md index 03bde3c..54fb4ff 100644 --- a/TERMINAL_ROADMAP.md +++ b/TERMINAL_ROADMAP.md @@ -62,7 +62,7 @@ - 实现于 `component.rs`(`Selection`/`selection_rects`/`point_to_cell`/鼠标处理)+ `core.rs::selection_text` + `core.rs::TerminalRow::text_in_cols`,均有单元测试。 3. **快捷键**:Ctrl-Shift-C 复制 / Ctrl-V 粘贴。 -**已知后续项(非阻塞)**:拖拽到视窗边缘时**没有自动滚动延伸选区**(需先滚到位再选);行内 wide+组合字符混排的列切片为近似。 +**已知后续项(非阻塞)**:拖拽到视窗边缘时**没有自动滚动延伸选区**(✅ 已通过上一轮 auto-scroll 实现);行内 wide+组合字符混排的列切片(✅ 已通过 grapheme_width 修正 text_in_cols / text_slice_cols)。 --- diff --git a/app/src/terminal/core.rs b/app/src/terminal/core.rs index 11a31b2..26c8e92 100644 --- a/app/src/terminal/core.rs +++ b/app/src/terminal/core.rs @@ -821,15 +821,22 @@ impl TerminalRow { if col >= to { break; } - // This segment overlaps [from, to): emit the columns inside range. - let chars: Vec = seg.text.chars().collect(); - for (i, ch) in chars.iter().enumerate() { - // Distribute chars across the segment's columns 1:1 (clamped). - let ch_col = col + i.min(seg.cols.saturating_sub(1)); - if ch_col >= from && ch_col < to { - out.push(*ch); + // Walk grapheme clusters and track actual grid-column width so + // that wide chars (2 cols) and combining marks (0 cols) are + // sliced correctly. + use unicode_segmentation::UnicodeSegmentation; + for grapheme in UnicodeSegmentation::graphemes(seg.text.as_str(), true) { + let w = grapheme_width(grapheme, false); + let ch_end = col + w; + // If this grapheme overlaps [from, to), emit it. + if ch_end > from && col < to { + out.push_str(grapheme); } + col = ch_end; } + // If our tracked `col` differs from `seg_end` (e.g. because + // `grapheme_width` disagrees with the vt100 grid), trust the + // grid column count for the next segment's baseline. col = seg_end; } out.trim_end().to_owned() @@ -947,9 +954,21 @@ fn rfind_literal_before_col(text: &str, query: &str, before_col: usize) -> Optio } fn text_slice_cols(text: &str, start_col: usize, end_col: usize) -> String { - let start = byte_index_for_char_col(text, start_col); - let end = byte_index_for_char_col(text, end_col); - text.get(start..end).unwrap_or_default().to_owned() + use unicode_segmentation::UnicodeSegmentation; + let mut out = String::new(); + let mut col = 0usize; + for grapheme in UnicodeSegmentation::graphemes(text, true) { + let w = grapheme_width(grapheme, false); + let ch_end = col + w; + if ch_end > start_col && col < end_col { + out.push_str(grapheme); + } + col = ch_end; + if col >= end_col { + break; + } + } + out } fn byte_index_for_char_col(text: &str, col: usize) -> usize {