diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 620267ad5..c0291b85d 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -5,6 +5,7 @@ const Allocator = std.mem.Allocator; const assert = std.debug.assert; const ansi = @import("ansi.zig"); const charsets = @import("charsets.zig"); +const fastmem = @import("../fastmem.zig"); const kitty = @import("kitty.zig"); const sgr = @import("sgr.zig"); const unicode = @import("../unicode/main.zig"); @@ -741,6 +742,100 @@ pub fn cursorDownScroll(self: *Screen) !void { } } +/// This scrolls the active area at and above the cursor. The lines below +/// the cursor are not scrolled. +pub fn cursorScrollAbove(self: *Screen) !void { + // If the cursor is on the bottom of the screen, its faster to use + // our specialized function for that case. + if (self.cursor.y == self.pages.rows - 1) { + return try self.cursorDownScroll(); + } + + defer self.assertIntegrity(); + + // Logic below assumes we always have at least one row that isn't moving + assert(self.cursor.y < self.pages.rows - 1); + + const old_pin = self.cursor.page_pin.*; + if (try self.pages.grow()) |new_page_node| { + // We allocated a new page and went to it. In this case, our new + // empty line is at the top of this page. + + // Prev is never null because pagelist asserts that we always have + // memory for at least two pages. This is an assertion. + assert(new_page_node.prev.? == old_pin.page); + const prev_page = &old_pin.page.data; + const new_page = &new_page_node.data; + + const prev_rows = prev_page.rows.ptr(prev_page.memory.ptr); + const new_rows = new_page.rows.ptr(new_page.memory.ptr); + const prev_last_row = &prev_rows[prev_page.size.rows - 1]; + + // First, copy the last row of the previous page to the top + // of our current page. + try new_page.cloneRowFrom( + prev_page, + &new_rows[0], + prev_last_row, + ); + + // Update our cursor metadata now. We call methods below that assert + // integrity in debug modes so we want to put ourselves in a + // consistent state first. + self.cursor.page_pin.* = self.cursor.page_pin.down(1).?; + self.cursorChangePin(self.cursor.page_pin.*); + const page_rac = self.cursor.page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + + // Third, clear the last row of the previous page. + self.clearCells( + prev_page, + prev_last_row, + prev_page.getCells(prev_last_row), + ); + var dirty = prev_page.dirtyBitSet(); + dirty.set(prev_page.size.rows - 1); + } else { + // In this case, it means grow() didn't allocate a new page. This + // allows us to perform a fast path by rotating rows on the same page. + assert(old_pin.page == self.cursor.page_pin.page); + self.cursor.page_pin.* = self.cursor.page_pin.down(1).?; + + const pin = self.cursor.page_pin; + const page = &self.cursor.page_pin.page.data; + + // Rotate the rows so that the newly created empty row is at the + // beginning. e.g. [ 0 1 2 3 ] in to [ 3 0 1 2 ]. + var rows = page.rows.ptr(page.memory.ptr); + fastmem.rotateOnceR(Row, rows[pin.y..page.size.rows]); + + // Mark all our rotated rows as dirty. + var dirty = page.dirtyBitSet(); + dirty.setRangeValue(.{ .start = pin.y, .end = page.size.rows }, true); + + // Setup our cursor caches after the rotation so it points to the + // correct data + const page_rac = self.cursor.page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + + // Note: we don't need to call cursorChangePin here because + // the pin page is the same so there is no accounting to do for + // styles or any of that. + } + + if (self.cursor.style_id != style.default_id) { + // The newly created line needs to be styled according to + // the bg color if it is set. + if (self.cursor.style.bgCell()) |blank_cell| { + const cell_current: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + const cells = cell_current - self.cursor.x; + @memset(cells[0..self.pages.cols], blank_cell); + } + } +} + /// Move the cursor down if we're not at the bottom of the screen. Otherwise /// scroll. Currently only used for testing. fn cursorDownOrScroll(self: *Screen) !void { @@ -3817,6 +3912,125 @@ test "Screen: scroll and clear ignore blank lines" { } } +test "Screen: scroll above same page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 10); + defer s.deinit(); + try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.cursorAbsolute(0, 1); + s.pages.clearDirty(); + try s.cursorScrollAbove(); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n\n3IJKL", contents); + } + { + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expect(cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 155, + .g = 0, + .b = 0, + }, cell.content.color_rgb); + } + + // Only y=1,2 are dirty because they are the ones that CHANGED contents + // (not just scroll). + try testing.expect(!s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); +} + +test "Screen: scroll above creates new page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 10); + defer s.deinit(); + + // We need to get the cursor to a new page + const first_page_size = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_size - 3) |_| try s.testWriteString("\n"); + + try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.cursorAbsolute(0, 1); + s.pages.clearDirty(); + + // Ensure we're still on the first page + try testing.expect(s.cursor.page_pin.page == s.pages.pages.first.?); + try s.cursorScrollAbove(); + + // const stderr = std.io.getStdErr().writer(); + // try s.pages.diagram(stderr); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n\n3IJKL", contents); + } + { + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expect(cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 155, + .g = 0, + .b = 0, + }, cell.content.color_rgb); + } + + // Only y=1 is dirty because they are the ones that CHANGED contents + try testing.expect(!s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(!s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); +} + +test "Screen: scroll above no scrollback bottom of page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 0); + defer s.deinit(); + + const first_page_size = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_size - 3) |_| try s.testWriteString("\n"); + + try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.cursorAbsolute(0, 1); + s.pages.clearDirty(); + try s.cursorScrollAbove(); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n\n3IJKL", contents); + } + { + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expect(cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 155, + .g = 0, + .b = 0, + }, cell.content.color_rgb); + } + + // Only y=1,2 are dirty because they are the ones that CHANGED contents + // (not just scroll). + try testing.expect(!s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); +} + test "Screen: clone" { const testing = std.testing; const alloc = testing.allocator;