fix(widgets): patch double-width glyphs straddling Clear's boundaries (closes #3947)

`yazi-widgets::Clear` delegates to `ratatui::widgets::Clear`, which only
resets cells inside the area. Two cases at the boundaries are not
handled:

  - **Left edge.** A double-width glyph at `area.x - 1` keeps its full
    two-cell visual width after the inner clear runs. Its right half
    spills into `area.x`, sits underneath whatever the caller draws on
    top, and renders as garbled overlap with the overlay's left border
    or title. The bug is most visible on Windows Terminal with
    directories whose names mix ASCII + CJK characters of an odd
    parity, e.g. `abc一二三四五六七八九` — pressing `f` for the input
    box or `w` for the task manager produces the screenshots in the
    issue.

  - **Right edge.** A double-width glyph whose left half sits at
    `area.right() - 1` (inside the area, so it gets cleared) leaves
    its continuation at `area.right()` (outside the area) as the empty
    string ratatui's buffer model uses for continuations. The terminal
    skips the empty cell and either renders stale content from the
    previous frame in that column or paints the neighbour glyph
    half-erased.

Patch both cells before delegating to the inner clear so the original
glyph widths are still visible to the check. Verified via five new
unit tests covering: left-edge double-width, left-edge single-width
(unaffected neighbour), right-edge continuation, right-edge with no
overhang (unaffected neighbour), and area touching the buffer edges
(no panic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Booyaka101 2026-05-10 09:09:29 +08:00
parent 247f925e53
commit 5ea30fca5c

View file

@ -1,6 +1,7 @@
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
use unicode_width::UnicodeWidthStr;
use yazi_adapter::ADAPTOR; use yazi_adapter::ADAPTOR;
pub static COLLISION: AtomicBool = AtomicBool::new(false); pub static COLLISION: AtomicBool = AtomicBool::new(false);
@ -13,6 +14,40 @@ impl Widget for Clear {
where where
Self: Sized, Self: Sized,
{ {
// Patch cells straddling the area's left/right edge before clearing.
//
// On the left edge, a double-width glyph in the layer below sitting
// at `area.x - 1` has its right half at `area.x`, *inside* the area.
// `ratatui::widgets::Clear` only resets cells inside the area, so
// after the clear the back-layer cell at `area.x - 1` would still
// claim two cells of visual width and overlap whatever the caller
// draws on top — the bug pattern in #3947.
//
// The mirrored case at the right edge: a double-width glyph at
// `area.right() - 1` has its left half inside the area (cleared
// later) and its continuation at `area.right()`, *outside* the
// area. The continuation cell is left as an empty string by
// ratatui's buffer model, so the terminal would either render
// stale content from a previous frame in that column or leave the
// neighbour glyph half-rendered. Blank the continuation here, while
// we still know it was a continuation, before the inside cell is
// cleared.
if area.x > 0 {
for y in area.top()..area.bottom() {
let cell = &mut buf[(area.x - 1, y)];
if cell.symbol().width() > 1 {
cell.set_symbol(" ");
}
}
}
if area.right() < buf.area().right() {
for y in area.top()..area.bottom() {
if buf[(area.right() - 1, y)].symbol().width() > 1 {
buf[(area.right(), y)].set_symbol(" ");
}
}
}
ratatui::widgets::Clear.render(area, buf); ratatui::widgets::Clear.render(area, buf);
let Some(r) = ADAPTOR.get().shown_load().and_then(|r| overlap(area, r)) else { let Some(r) = ADAPTOR.get().shown_load().and_then(|r| overlap(area, r)) else {
@ -44,3 +79,74 @@ fn overlap(a: Rect, b: Rect) -> Option<Rect> {
let height = (a.y + a.height).min(b.y + b.height) - y; let height = (a.y + a.height).min(b.y + b.height) - y;
Some(Rect { x, y, width, height }) Some(Rect { x, y, width, height })
} }
#[cfg(test)]
mod tests {
use ratatui::style::Style;
use super::*;
// Fill `buf` with `s` at row 0 and run our `Clear` over `area`.
// Returns the row 0 symbols in order, including the empty continuation
// strings that ratatui leaves for the right halves of double-width
// glyphs. This makes the asymmetry between "single-width spaces" and
// "stale continuation markers" visible in the assertions.
fn run(s: &str, buf_w: u16, area: Rect) -> Vec<String> {
let mut buf = Buffer::empty(Rect::new(0, 0, buf_w, 1));
buf.set_string(0, 0, s, Style::default());
Clear.render(area, &mut buf);
(0..buf_w).map(|x| buf[(x, 0)].symbol().to_string()).collect()
}
#[test]
fn left_edge_double_width_replaced_with_space() {
// Back layer: a b c 字 字
// 0 1 2 3 4 <- column
// Overlay starts at column 4 (right half of 字). Pre-fix, cell 3
// keeps "字" and overlaps the overlay's left edge.
let row = run("abc字", 6, Rect::new(4, 0, 2, 1));
assert_eq!(row[3], " ", "double-width at the left edge should be blanked");
// Cells inside the area are cleared by ratatui::widgets::Clear.
assert_eq!(row[4], " ");
assert_eq!(row[5], " ");
}
#[test]
fn left_edge_single_width_left_alone() {
// Cell 3 is a plain ASCII char. We must NOT blank it.
let row = run("abcde", 6, Rect::new(4, 0, 2, 1));
assert_eq!(row[3], "d");
}
#[test]
fn right_edge_continuation_cleared() {
// Back layer: a b c 字 d
// 0 1 2 3 4 5 (字 occupies cols 3 and 4)
// Overlay covers columns 0..4. The left half of 字 is the last
// cell *inside* the area and gets reset to " " by the inner clear.
// The right half at column 4 is the continuation cell, marked with
// an empty symbol by ratatui's buffer model and *outside* the area
// — pre-fix it stays empty and the terminal renders stale content
// there; post-fix it's blanked to " ".
let row = run("abc字d", 6, Rect::new(0, 0, 4, 1));
assert_eq!(row[3], " ");
assert_eq!(row[4], " ", "stranded continuation should be blanked");
assert_eq!(row[5], "d", "neighbour past the continuation must be left alone");
}
#[test]
fn right_edge_with_no_overhang_leaves_neighbour_alone() {
// Cell at area.right() - 1 is single-width, so cell at area.right()
// is a normal back-layer cell and must not be overwritten.
let row = run("abcde", 6, Rect::new(0, 0, 3, 1));
assert_eq!(row[3], "d");
}
#[test]
fn area_at_buffer_edges_does_not_panic() {
// area.x == 0 and area.right() == buf_w must not be touched (the
// boundary cells we'd patch don't exist).
let row = run("abcd", 4, Rect::new(0, 0, 4, 1));
assert_eq!(row, vec![" ", " ", " ", " "]);
}
}