mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-16 08:46:08 +03:00
fix(terminal): insert/deleteLines boundary cond.s
Introduced a helper function for correctly handling boundary conditions in insertLines and deleteLines. Also adds a whole host of tests for said conditions in deleteLines, tests not duplicated for insertLines because they both use the same helper function.
This commit is contained in:
@ -2147,6 +2147,25 @@ pub fn dumpStringAlloc(
|
||||
return try builder.toOwnedSlice();
|
||||
}
|
||||
|
||||
/// You should use dumpString, this is a restricted version mostly for
|
||||
/// legacy and convenience reasons for unit tests.
|
||||
pub fn dumpStringAllocUnwrapped(
|
||||
self: *const Screen,
|
||||
alloc: Allocator,
|
||||
tl: point.Point,
|
||||
) ![]const u8 {
|
||||
var builder = std.ArrayList(u8).init(alloc);
|
||||
defer builder.deinit();
|
||||
|
||||
try self.dumpString(builder.writer(), .{
|
||||
.tl = self.pages.getTopLeft(tl),
|
||||
.br = self.pages.getBottomRight(tl) orelse return error.UnknownPoint,
|
||||
.unwrap = true,
|
||||
});
|
||||
|
||||
return try builder.toOwnedSlice();
|
||||
}
|
||||
|
||||
/// This is basically a really jank version of Terminal.printString. We
|
||||
/// have to reimplement it here because we want a way to print to the screen
|
||||
/// to test it but don't want all the features of Terminal.
|
||||
|
@ -1306,6 +1306,63 @@ pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void {
|
||||
});
|
||||
}
|
||||
|
||||
/// To be called before shifting a row (as in insertLines and deleteLines)
|
||||
///
|
||||
/// Takes care of boundary conditions such as potentially split wide chars
|
||||
/// across scrolling region boundaries and orphaned spacer heads at line
|
||||
/// ends.
|
||||
fn rowWillBeShifted(
|
||||
self: *Terminal,
|
||||
page: *Page,
|
||||
row: *Row,
|
||||
) void {
|
||||
const cells = row.cells.ptr(page.memory.ptr);
|
||||
|
||||
// If our scrolling region includes the rightmost column then we
|
||||
// need to turn any spacer heads in to normal empty cells, since
|
||||
// once we move them they no longer correspond with soft-wrapped
|
||||
// wide characters.
|
||||
//
|
||||
// If it contains either of the 2 leftmost columns, then the wide
|
||||
// characters in the first column which may be associated with a
|
||||
// spacer head will be either moved or cleared, so we also need
|
||||
// to turn the spacer heads in to empty cells in that case.
|
||||
if (self.scrolling_region.right == self.cols - 1 or
|
||||
self.scrolling_region.left < 2
|
||||
) {
|
||||
const end_cell: *Cell = &cells[page.size.cols - 1];
|
||||
if (end_cell.wide == .spacer_head) {
|
||||
end_cell.wide = .narrow;
|
||||
}
|
||||
}
|
||||
|
||||
// If the leftmost or rightmost cells of our scrolling region
|
||||
// are parts of wide chars, we need to clear the cells' contents
|
||||
// since they'd be split by the move.
|
||||
const left_cell: *Cell = &cells[self.scrolling_region.left];
|
||||
const right_cell: *Cell = &cells[self.scrolling_region.right];
|
||||
|
||||
if (left_cell.wide == .spacer_tail) {
|
||||
const wide_cell: *Cell = &cells[self.scrolling_region.left - 1];
|
||||
if (wide_cell.hasGrapheme()) {
|
||||
page.clearGrapheme(row, wide_cell);
|
||||
}
|
||||
wide_cell.content.codepoint = 0;
|
||||
wide_cell.wide = .narrow;
|
||||
left_cell.wide = .narrow;
|
||||
}
|
||||
|
||||
if (right_cell.wide == .wide) {
|
||||
const tail_cell: *Cell = &cells[self.scrolling_region.right + 1];
|
||||
if (right_cell.hasGrapheme()) {
|
||||
page.clearGrapheme(row, right_cell);
|
||||
}
|
||||
right_cell.content.codepoint = 0;
|
||||
right_cell.wide = .narrow;
|
||||
tail_cell.wide = .narrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert amount lines at the current cursor row. The contents of the line
|
||||
/// at the current cursor row and below (to the bottom-most line in the
|
||||
/// scrolling region) are shifted down by amount lines. The contents of the
|
||||
@ -1364,25 +1421,15 @@ pub fn insertLines(self: *Terminal, count: usize) void {
|
||||
const src: *Row = src_rac.row;
|
||||
const dst: *Row = dst_rac.row;
|
||||
|
||||
// If our scrolling region includes the rightmost column then we
|
||||
// need to turn any spacer heads in to normal empty cells, since
|
||||
// once we move them they no longer correspond with soft-wrapped
|
||||
// wide characters.
|
||||
if (self.scrolling_region.right == self.cols - 1) {
|
||||
const src_end_cell: *Cell = @ptrCast(@as([*]Cell, @ptrCast(src_rac.cell)) + p.page.data.size.cols - 1);
|
||||
const dst_end_cell: *Cell = @ptrCast(@as([*]Cell, @ptrCast(dst_rac.cell)) + dst_p.page.data.size.cols - 1);
|
||||
if (dst_end_cell.wide == .spacer_head) {
|
||||
dst_end_cell.wide = .narrow;
|
||||
}
|
||||
if (src_end_cell.wide == .spacer_head) {
|
||||
src_end_cell.wide = .narrow;
|
||||
}
|
||||
self.rowWillBeShifted(&p.page.data, src);
|
||||
self.rowWillBeShifted(&dst_p.page.data, dst);
|
||||
|
||||
// If our scrolling region is full width, then we unset wrap.
|
||||
if (self.scrolling_region.left == 0) {
|
||||
dst.wrap = false;
|
||||
src.wrap = false;
|
||||
}
|
||||
// If our scrolling region is full width, then we unset wrap.
|
||||
if (!left_right) {
|
||||
dst.wrap = false;
|
||||
src.wrap = false;
|
||||
dst.wrap_continuation = false;
|
||||
src.wrap_continuation = false;
|
||||
}
|
||||
|
||||
// If our page doesn't match, then we need to do a copy from
|
||||
@ -1516,25 +1563,15 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
|
||||
const src: *Row = src_rac.row;
|
||||
const dst: *Row = dst_rac.row;
|
||||
|
||||
// If our scrolling region includes the rightmost column then we
|
||||
// need to turn any spacer heads in to normal empty cells, since
|
||||
// once we move them they no longer correspond with soft-wrapped
|
||||
// wide characters.
|
||||
if (self.scrolling_region.right == self.cols - 1) {
|
||||
const src_end_cell: *Cell = @ptrCast(@as([*]Cell, @ptrCast(src_rac.cell)) + src_p.page.data.size.cols - 1);
|
||||
const dst_end_cell: *Cell = @ptrCast(@as([*]Cell, @ptrCast(dst_rac.cell)) + p.page.data.size.cols - 1);
|
||||
if (dst_end_cell.wide == .spacer_head) {
|
||||
dst_end_cell.wide = .narrow;
|
||||
}
|
||||
if (src_end_cell.wide == .spacer_head) {
|
||||
src_end_cell.wide = .narrow;
|
||||
}
|
||||
self.rowWillBeShifted(&src_p.page.data, src);
|
||||
self.rowWillBeShifted(&p.page.data, dst);
|
||||
|
||||
// If our scrolling region is full width, then we unset wrap.
|
||||
if (self.scrolling_region.left == 0) {
|
||||
dst.wrap = false;
|
||||
src.wrap = false;
|
||||
}
|
||||
// If our scrolling region is full width, then we unset wrap.
|
||||
if (!left_right) {
|
||||
dst.wrap = false;
|
||||
src.wrap = false;
|
||||
dst.wrap_continuation = false;
|
||||
src.wrap_continuation = false;
|
||||
}
|
||||
|
||||
if (src_p.page != p.page) {
|
||||
@ -2399,6 +2436,11 @@ pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 {
|
||||
return try self.screen.dumpStringAlloc(alloc, .{ .viewport = .{} });
|
||||
}
|
||||
|
||||
/// Same as plainString, but respects row wrap state when building the string.
|
||||
pub fn plainStringUnwrapped(self: *Terminal, alloc: Allocator) ![]const u8 {
|
||||
return try self.screen.dumpStringAllocUnwrapped(alloc, .{ .viewport = .{} });
|
||||
}
|
||||
|
||||
/// Full reset
|
||||
pub fn fullReset(self: *Terminal) void {
|
||||
// Switch back to primary screen and clear it. We do not restore cursor
|
||||
@ -6133,6 +6175,243 @@ test "Terminal: deleteLines left/right scroll region high count" {
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines wide character spacer head" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Initial value
|
||||
// +-----+
|
||||
// |AAAAA| < Wrapped
|
||||
// |BBBB*| < Wrapped (continued)
|
||||
// |WWCCC| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// where * represents a spacer head cell
|
||||
// and WW is the wide character.
|
||||
try t.printString("AAAAABBBB\u{1F600}CCC");
|
||||
|
||||
// Delete the top line
|
||||
// +-----+
|
||||
// |BBBB | < Non-wrapped
|
||||
// |WWCCC| < Non-wrapped
|
||||
// | | < Non-wrapped
|
||||
// +-----+
|
||||
// This should convert the spacer head to
|
||||
// a regular empty cell, and un-set wrap.
|
||||
t.setCursorPos(1, 1);
|
||||
t.deleteLines(1);
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
|
||||
defer testing.allocator.free(unwrapped_str);
|
||||
try testing.expectEqualStrings("BBBB \n\u{1F600}CCC", str);
|
||||
try testing.expectEqualStrings("BBBB \n\u{1F600}CCC", unwrapped_str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines wide character spacer head left scroll margin" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Initial value
|
||||
// +-----+
|
||||
// |AAAAA| < Wrapped
|
||||
// |BBBB*| < Wrapped (continued)
|
||||
// |WWCCC| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// where * represents a spacer head cell
|
||||
// and WW is the wide character.
|
||||
try t.printString("AAAAABBBB\u{1F600}CCC");
|
||||
|
||||
t.scrolling_region.left = 2;
|
||||
|
||||
// Delete the top line
|
||||
// ### <- scrolling region
|
||||
// +-----+
|
||||
// |AABB | < Wrapped
|
||||
// |BBCCC| < Wrapped (continued)
|
||||
// |WW | < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// This should convert the spacer head to
|
||||
// a regular empty cell, but due to the
|
||||
// left scrolling margin, wrap state should
|
||||
// remain.
|
||||
t.setCursorPos(1, 3);
|
||||
t.deleteLines(1);
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
|
||||
defer testing.allocator.free(unwrapped_str);
|
||||
try testing.expectEqualStrings("AABB \nBBCCC\n\u{1F600}", str);
|
||||
try testing.expectEqualStrings("AABB BBCCC\u{1F600}", unwrapped_str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines wide character spacer head right scroll margin" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Initial value
|
||||
// +-----+
|
||||
// |AAAAA| < Wrapped
|
||||
// |BBBB*| < Wrapped (continued)
|
||||
// |WWCCC| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// where * represents a spacer head cell
|
||||
// and WW is the wide character.
|
||||
try t.printString("AAAAABBBB\u{1F600}CCC");
|
||||
|
||||
t.scrolling_region.right = 3;
|
||||
|
||||
// Delete the top line
|
||||
// #### <- scrolling region
|
||||
// +-----+
|
||||
// |BBBBA| < Wrapped
|
||||
// |WWCC | < Wrapped (continued)
|
||||
// | C| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// This should convert the spacer head to
|
||||
// a regular empty cell, but due to the
|
||||
// right scrolling margin, wrap state should
|
||||
// remain.
|
||||
t.setCursorPos(1, 1);
|
||||
t.deleteLines(1);
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
|
||||
defer testing.allocator.free(unwrapped_str);
|
||||
try testing.expectEqualStrings("BBBBA\n\u{1F600}CC \n C", str);
|
||||
try testing.expectEqualStrings("BBBBA\u{1F600}CC C", unwrapped_str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines wide character spacer head left and right scroll margin" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Initial value
|
||||
// +-----+
|
||||
// |AAAAA| < Wrapped
|
||||
// |BBBB*| < Wrapped (continued)
|
||||
// |WWCCC| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// where * represents a spacer head cell
|
||||
// and WW is the wide character.
|
||||
try t.printString("AAAAABBBB\u{1F600}CCC");
|
||||
|
||||
t.scrolling_region.right = 3;
|
||||
t.scrolling_region.left = 2;
|
||||
|
||||
// Delete the top line
|
||||
// ## <- scrolling region
|
||||
// +-----+
|
||||
// |AABBA| < Wrapped
|
||||
// |BBCC*| < Wrapped (continued)
|
||||
// |WW C| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// Because there is both a left scrolling
|
||||
// margin > 1 and a right scrolling margin
|
||||
// the spacer head should remain, and the
|
||||
// wrap state should be untouched.
|
||||
t.setCursorPos(1, 3);
|
||||
t.deleteLines(1);
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
|
||||
defer testing.allocator.free(unwrapped_str);
|
||||
try testing.expectEqualStrings("AABBA\nBBCC\n\u{1F600} C", str);
|
||||
try testing.expectEqualStrings("AABBABBCC\u{1F600} C", unwrapped_str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines wide character spacer head left (< 2) and right scroll margin" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Initial value
|
||||
// +-----+
|
||||
// |AAAAA| < Wrapped
|
||||
// |BBBB*| < Wrapped (continued)
|
||||
// |WWCCC| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// where * represents a spacer head cell
|
||||
// and WW is the wide character.
|
||||
try t.printString("AAAAABBBB\u{1F600}CCC");
|
||||
|
||||
t.scrolling_region.right = 3;
|
||||
t.scrolling_region.left = 1;
|
||||
|
||||
// Delete the top line
|
||||
// ### <- scrolling region
|
||||
// +-----+
|
||||
// |ABBBA| < Wrapped
|
||||
// |B CC | < Wrapped (continued)
|
||||
// | C| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// Because the left margin is 1, the wide
|
||||
// char is split, and therefore removed,
|
||||
// along with the spacer head - however,
|
||||
// wrap state should be untouched.
|
||||
t.setCursorPos(1, 2);
|
||||
t.deleteLines(1);
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
|
||||
defer testing.allocator.free(unwrapped_str);
|
||||
try testing.expectEqualStrings("ABBBA\nB CC \n C", str);
|
||||
try testing.expectEqualStrings("ABBBAB CC C", unwrapped_str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines wide characters split by left/right scroll region boundaries" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 2 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Initial value
|
||||
// +-----+
|
||||
// |AAAAA|
|
||||
// |WWBWW|
|
||||
// +-----+
|
||||
// where WW represents a wide character
|
||||
try t.printString("AAAAA\n\u{1F600}B\u{1F600}");
|
||||
|
||||
t.scrolling_region.right = 3;
|
||||
t.scrolling_region.left = 1;
|
||||
|
||||
// Delete the top line
|
||||
// ### <- scrolling region
|
||||
// +-----+
|
||||
// |A B A|
|
||||
// | |
|
||||
// +-----+
|
||||
// The two wide chars, because they're
|
||||
// split by the edge of the scrolling
|
||||
// region, get removed.
|
||||
t.setCursorPos(1, 2);
|
||||
t.deleteLines(1);
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("A B A\n ", str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines zero" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 2, .rows = 5 });
|
||||
|
Reference in New Issue
Block a user