Ratatui VS16 Emoji 黑块与边框残留
在终端 TUI 的文档视图中上下滚动包含 emoji 的内容时,会出现三个看似独立的渲染问题:
☀️、🌤️等 emoji 后面偶尔出现一个终端默认背景色的黑块;- 同一行右侧的面板边框偶尔错开一列;
- 满宽 Box 滚动离开 viewport 后,右侧仍残留一段
│边框。
最终确认,这三个现象来自同一个 Ratatui/Crossterm 宽字符 diff 问题。
先区分两类宽字符问题
此前悬浮输入框和 dialog 还遇到过另一类问题:下层有一个双列 CJK 或 emoji,上层浮窗只覆盖了它的一部分,留在浮窗外面的 continuation cell 没有被清掉。
那个问题适合在 Clear 之前检查浮窗左右两条竖边,只删除真正跨越边界的宽字符。它属于"两个 widget 的覆盖边界"问题。
本次黑块则不需要浮窗。只要滚动内容里存在带 VS16 的 emoji,就可能出现。它属于"最终 buffer 到终端的 diff 输出"问题。两者不能用同一个刷新外围 cell 的办法处理。
VS16 是触发条件
截图中的 ☀️ 并不只是 U+2600,实际是:
U+2600 SUN + U+FE0F VARIATION SELECTOR-16
VS16 要求采用 emoji presentation。🌤️、⚠️、🏳️🌈 等序列也包含 U+FE0F。
Ratatui 0.30.2 对这类 grapheme 有一条特殊 diff 路径。写入双列字符时,buffer 大致变成:
x emoji leading cell,保留文字与背景 style
x + 1 continuation cell,被 reset 为默认空 cell
普通双列字符会让 diff 跳过 x + 1。但 Ratatui 为了兼容部分不能自动清理 emoji 尾列的终端,会对包含 VS16 的字符额外检查 continuation cell。当上一帧和当前帧的尾列 symbol 不同时,它会显式输出这个空 cell。
问题是这个空 cell 已经被 reset(),背景色是 Color::Reset,而正文背景通常是自定义深色(如 #181825)。
为什么黑块和边框错位同时出现
CrosstermBackend 判断是否需要移动光标时,只比较当前更新坐标是否等于上一个坐标加一:
if !matches!(last_pos, Some(p) if x == p.x + 1 && y == p.y) {
queue!(writer, MoveTo(x, y))?;
}
它没有考虑上一个 symbol 的显示宽度。
绘制双列 emoji 后,终端物理光标已经从 x 移到 x + 2。Ratatui 接着提交逻辑坐标 x + 1 的 continuation cell,Crossterm 却认为这是相邻位置,不发送 MoveTo。于是空格实际写在 x + 2:
VS16 emoji
-> Ratatui 额外提交尾随空 cell
-> Crossterm 漏掉 MoveTo
-> 默认背景空格写到 emoji 后面
-> 本行后续输出整体偏移一列
这解释了全部症状:
- 默认背景空格就是 emoji 后面的黑块;
- 本行后续内容偏移导致边框错位;
- Box 的右边框被实际写到逻辑边界外一列,下一帧只知道清理逻辑位置,因此滚动后外侧
│留了下来。
现象之所以偶发,是因为 Ratatui 只在前后两帧对应 cell 的 symbol 满足特定变化条件时,才会提交 VS16 continuation cell。静止画面通常正常,滚动最容易触发。
尝试过但不成立的方案
强制外围 cell 每帧更新
给浮窗或 viewport 外侧 continuation cell 设置 AlwaysUpdate,确实可能擦掉旧边框,但也会把 reset 后的默认背景空格持续写进终端,直接制造黑块。错误输出还会继续推移同行边框。
只刷新 viewport 内侧
如果 Box 的边框已经被错误地画在逻辑 viewport 外面,Ratatui 的 buffer 根本不知道那个物理 cell 被写过。只重画逻辑内侧无法清除它。
自定义 Backend
让 Backend 按 symbol 宽度跟踪物理光标可以修正漏掉的 MoveTo,但不能单独解决 continuation cell 使用默认背景的问题。完整修复需要同时处理 Ratatui core 和 Crossterm backend,维护成本较高。
最终方案:跳过 VS16 continuation 输出
在所有 widget、通知和 overlay 都完成渲染后,对最终 frame buffer 做一次 normalization:
- 扫描当前 terminal viewport 中的 cells;
- 找到宽度大于 1 且包含
U+FE0F的 grapheme leading cell; - 将它覆盖的 continuation cells 标记为
CellDiffOption::Skip。
简化后的核心逻辑如下:
fn skip_vs16_continuation_cells(buffer: &mut Buffer) {
for (x, y) in viewport_cells(buffer.area) {
let cell = &buffer[(x, y)];
let symbol = cell.symbol();
let width = UnicodeWidthStr::width(symbol);
if width > 1 && symbol.contains('\u{fe0f}') {
for offset in 1..width {
buffer[(x + offset as u16, y)]
.set_diff_option(CellDiffOption::Skip);
}
}
}
}
这样,带 VS16 的 emoji 会走得和普通 CJK、😀 等宽字符一样:只提交 leading cell,终端自然占用其完整宽度,不再额外输出 reset 背景空格。
这个 pass 不清空 cell、不使用 AlwaysUpdate、不写 viewport 外侧,也不需要自定义 Backend;扫描范围只在当前 viewport 内,每帧成本与屏幕面积成正比,与文档总行数无关。