diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 61af34338..4467db866 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -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. diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 6dbe2345a..70bf9cfcc 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -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 });