fix(terminal): use grapheme_width for accurate column slicing

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
This commit is contained in:
zhangheng
2026-06-25 12:37:11 +08:00
parent 8d73d6eabc
commit b7a8bf49c4
2 changed files with 30 additions and 11 deletions

View File

@@ -62,7 +62,7 @@
- 实现于 `component.rs``Selection`/`selection_rects`/`point_to_cell`/鼠标处理)+ `core.rs::selection_text` + `core.rs::TerminalRow::text_in_cols`,均有单元测试。 - 实现于 `component.rs``Selection`/`selection_rects`/`point_to_cell`/鼠标处理)+ `core.rs::selection_text` + `core.rs::TerminalRow::text_in_cols`,均有单元测试。
3. **快捷键**Ctrl-Shift-C 复制 / Ctrl-V 粘贴。 3. **快捷键**Ctrl-Shift-C 复制 / Ctrl-V 粘贴。
**已知后续项(非阻塞)**:拖拽到视窗边缘时**没有自动滚动延伸选区**需先滚到位再选);行内 wide+组合字符混排的列切片为近似 **已知后续项(非阻塞)**:拖拽到视窗边缘时**没有自动滚动延伸选区**✅ 已通过上一轮 auto-scroll 实现);行内 wide+组合字符混排的列切片(✅ 已通过 grapheme_width 修正 text_in_cols / text_slice_cols
--- ---

View File

@@ -821,15 +821,22 @@ impl TerminalRow {
if col >= to { if col >= to {
break; break;
} }
// This segment overlaps [from, to): emit the columns inside range. // Walk grapheme clusters and track actual grid-column width so
let chars: Vec<char> = seg.text.chars().collect(); // that wide chars (2 cols) and combining marks (0 cols) are
for (i, ch) in chars.iter().enumerate() { // sliced correctly.
// Distribute chars across the segment's columns 1:1 (clamped). use unicode_segmentation::UnicodeSegmentation;
let ch_col = col + i.min(seg.cols.saturating_sub(1)); for grapheme in UnicodeSegmentation::graphemes(seg.text.as_str(), true) {
if ch_col >= from && ch_col < to { let w = grapheme_width(grapheme, false);
out.push(*ch); 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; col = seg_end;
} }
out.trim_end().to_owned() 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 { fn text_slice_cols(text: &str, start_col: usize, end_col: usize) -> String {
let start = byte_index_for_char_col(text, start_col); use unicode_segmentation::UnicodeSegmentation;
let end = byte_index_for_char_col(text, end_col); let mut out = String::new();
text.get(start..end).unwrap_or_default().to_owned() 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 { fn byte_index_for_char_col(text: &str, col: usize) -> usize {