From eb722cb26dfe3fb5dc481181ae463940492cd742 Mon Sep 17 00:00:00 2001 From: Fredrik Fornwall Date: Fri, 28 Aug 2026 01:13:25 +0200 Subject: [PATCH] terminal: mark the previous row dirty when clearing its spacer head Erasing a wrapped wide character at the start of a row (ECH or DCH) also clears the spacer head it left at the end of the previous row, but that row was never marked dirty. With both rows visible, an incremental render kept the stale spacer head on screen until something unrelated redrew that row. The clearing happens in the row-start branch of splitCellBoundary. clearCells doesn't do dirty tracking, and both callers only mark the cursor row, so mark the previous row at the point it's mutated. The added dirty assertions fail without the fix. --- src/terminal/Screen.zig | 5 +++++ src/terminal/Terminal.zig | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 3b7278e7b..208e276a2 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1949,6 +1949,11 @@ pub fn splitCellBoundary( p_rac.row, p_cells[p_row.node.cols() - 1 ..][0..1], ); + + // `clearCells` does not mark rows dirty, and our + // callers only mark the cursor row, so mark the + // previous row here. + p_row.markDirty(); } } } diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 0a881a30c..93cad0ca8 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -9922,6 +9922,38 @@ test "Terminal: eraseChars wide char wrap boundary conditions" { } } +test "Terminal: eraseChars clearing wrapped wide char marks spacer head row dirty" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 3, .cols = 5 }); + defer t.deinit(alloc); + + // The wide char doesn't fit so it wraps, leaving a spacer head at + // the end of the first row. + try t.printString("ABCD字"); + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + try testing.expectEqual(Cell.Wide.spacer_head, list_cell.cell.wide); + try testing.expect(list_cell.row.wrap); + } + + t.setCursorPos(2, 1); + t.clearDirty(); + t.eraseChars(1); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); + + // Erasing the wide char also clears the spacer head on the previous + // row, so that row must be dirty too. + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 2 } })); + + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + try testing.expectEqual(Cell.Wide.narrow, list_cell.cell.wide); + } +} + test "Terminal: reverseIndex" { const alloc = testing.allocator; const io_impl = testing.io; @@ -13356,9 +13388,16 @@ test "Terminal: deleteChars wide char wrap boundary conditions" { } t.setCursorPos(2, 2); + t.clearDirty(); t.deleteChars(3); t.screens.active.cursor.page_pin.node.page().assertIntegrity(); + // Deleting the wide char also clears the spacer head on the previous + // row, so that row must be dirty too. + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 2 } })); + { const str = try t.plainString(alloc); defer testing.allocator.free(str);