Merge pull request #2115 from ghostty-org/index

Index should create scrollback anytime top scroll region is top line
This commit is contained in:
Mitchell Hashimoto
2024-08-18 10:34:50 -07:00
committed by GitHub
2 changed files with 509 additions and 61 deletions

View File

@ -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,143 @@ 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()) |_| {
try self.cursorScrollAboveRotate();
} else {
// In this case, it means grow() didn't allocate a new page.
if (self.cursor.page_pin.page == self.pages.pages.last) {
// If we're on the last page we can do a very fast path because
// all the rows we need to move around are within a single 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.
} else {
// We didn't grow pages but our cursor isn't on the last page.
// In this case we need to do more work because we need to copy
// elements between pages.
//
// An example scenario of this is shown below:
//
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4302 |1A00000000| | 0
// 4303 |2B00000000| | 1
// :^ : : = PIN 0
// 4304 |3C00000000| | 2
// +----------+ :
// +----------+ : = PAGE 1
// 0 |4D00000000| | 3
// 1 |5E00000000| | 4
// +----------+ :
// +-------------+
try self.cursorScrollAboveRotate();
}
}
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);
}
}
}
fn cursorScrollAboveRotate(self: *Screen) !void {
self.cursor.page_pin.* = self.cursor.page_pin.down(1).?;
// Go through each of the pages following our pin, shift all rows
// down by one, and copy the last row of the previous page.
var current = self.pages.pages.last.?;
while (current != self.cursor.page_pin.page) : (current = current.prev.?) {
const prev = current.prev.?;
const prev_page = &prev.data;
const cur_page = &current.data;
const prev_rows = prev_page.rows.ptr(prev_page.memory.ptr);
const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr);
// Rotate the pages down: [ 0 1 2 3 ] => [ 3 0 1 2 ]
fastmem.rotateOnceR(Row, cur_rows[0..cur_page.size.rows]);
// Copy the last row of the previous page to the top of current.
try cur_page.cloneRowFrom(
prev_page,
&cur_rows[0],
&prev_rows[prev_page.size.rows - 1],
);
// All rows we rotated are dirty
var dirty = cur_page.dirtyBitSet();
dirty.setRangeValue(.{ .start = 0, .end = cur_page.size.rows }, true);
}
// Our current is our cursor page, we need to rotate down from
// our cursor and clear our row.
assert(current == self.cursor.page_pin.page);
const cur_page = &current.data;
const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr);
fastmem.rotateOnceR(Row, cur_rows[self.cursor.page_pin.y..cur_page.size.rows]);
self.clearCells(
cur_page,
&cur_rows[0],
cur_page.getCells(&cur_rows[0]),
);
// Set all the rows we rotated and cleared dirty
var dirty = cur_page.dirtyBitSet();
dirty.setRangeValue(
.{ .start = self.cursor.page_pin.y, .end = cur_page.size.rows },
true,
);
// Setup cursor cache data after all the rotations so our
// row is valid.
const page_rac = self.cursor.page_pin.rowAndCell();
self.cursor.page_row = page_rac.row;
self.cursor.page_cell = page_rac.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 +3955,255 @@ 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 same page but cursor on previous page" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 5, 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("1A\n2B\n3C\n4D\n5E");
s.cursorAbsolute(0, 1);
s.pages.clearDirty();
// Ensure we're still on the first page and have a second
try testing.expect(s.cursor.page_pin.page == s.pages.pages.first.?);
try testing.expect(s.pages.pages.first.?.next != null);
// At this point:
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4303 |1A00000000| | 0
// 4304 |2B00000000| | 1
// :^ : : = PIN 0
// +----------+ :
// +----------+ : = PAGE 1
// 0 |3C00000000| | 2
// 1 |4D00000000| | 3
// 2 |5E00000000| | 4
// +----------+ :
// +-------------+
try s.cursorScrollAbove();
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2B\n\n3C\n4D\n5E", 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);
}
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 same page but cursor on previous page last row" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 5, 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 - 2) |_| try s.testWriteString("\n");
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } });
try s.testWriteString("1A\n2B\n3C\n4D\n5E");
s.cursorAbsolute(0, 1);
s.pages.clearDirty();
// Ensure we're still on the first page and have a second
try testing.expect(s.cursor.page_pin.page == s.pages.pages.first.?);
try testing.expect(s.pages.pages.first.?.next != null);
// At this point:
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4303 |1A00000000| | 0
// 4304 |2B00000000| | 1
// :^ : : = PIN 0
// +----------+ :
// +----------+ : = PAGE 1
// 0 |3C00000000| | 2
// 1 |4D00000000| | 3
// 2 |5E00000000| | 4
// +----------+ :
// +-------------+
try s.cursorScrollAbove();
// +----------+ = PAGE 0
// ... : :
// 4303 |1A00000000|
// +-------------+ ACTIVE
// 4304 |2B00000000| | 0
// +----------+ :
// +----------+ : = PAGE 1
// 0 | | | 1
// :^ : : = PIN 0
// 1 |3C00000000| | 2
// 2 |4D00000000| | 3
// 3 |5E00000000| | 4
// +----------+ :
// +-------------+
// try s.pages.diagram(std.io.getStdErr().writer());
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2B\n\n3C\n4D\n5E", 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);
}
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 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;

View File

@ -1109,15 +1109,15 @@ pub fn index(self: *Terminal) !void {
// Scrolling dirties the images because it updates their placements pins.
self.screen.kitty_images.dirty = true;
// If our scrolling region is the full screen, we create scrollback.
// Otherwise, we simply scroll the region.
// If our scrolling region is at the top, we create scrollback.
if (self.scrolling_region.top == 0 and
self.scrolling_region.bottom == self.rows - 1 and
self.scrolling_region.left == 0 and
self.scrolling_region.right == self.cols - 1)
{
try self.screen.cursorDownScroll();
} else {
try self.screen.cursorScrollAbove();
return;
}
// Slow path for left and right scrolling region margins.
if (self.scrolling_region.left != 0 or
self.scrolling_region.right != self.cols - 1 or
@ -1164,7 +1164,6 @@ pub fn index(self: *Terminal) !void {
self.screen.cursor.style = .{};
self.screen.manualStyleUpdate() catch unreachable;
};
}
return;
}
@ -6438,10 +6437,11 @@ test "Terminal: index bottom of scroll region with hyperlinks" {
test "Terminal: index bottom of scroll region clear hyperlinks" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 });
defer t.deinit(alloc);
t.setTopAndBottomMargin(1, 2);
t.setTopAndBottomMargin(2, 3);
t.setCursorPos(2, 1);
try t.screen.startHyperlink("http://example.com", null);
try t.print('A');
t.screen.endHyperlink();
@ -6455,10 +6455,10 @@ test "Terminal: index bottom of scroll region clear hyperlinks" {
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("B\nC", str);
try testing.expectEqualStrings("\nB\nC", str);
}
for (0..2) |y| {
for (1..3) |y| {
const list_cell = t.screen.pages.getCell(.{ .viewport = .{
.x = 0,
.y = @intCast(y),
@ -6597,11 +6597,36 @@ test "Terminal: index inside left/right margin" {
}
}
test "Terminal: index bottom of scroll region" {
test "Terminal: index bottom of scroll region creates scrollback" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
t.setTopAndBottomMargin(1, 3);
try t.printString("1\n2\n3");
t.setCursorPos(4, 1);
try t.print('X');
t.setCursorPos(3, 1);
try t.index();
try t.print('Y');
{
const str = try t.screen.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer testing.allocator.free(str);
try testing.expectEqualStrings("2\n3\nY\nX", str);
}
{
const str = try t.screen.dumpStringAlloc(alloc, .{ .screen = .{} });
defer testing.allocator.free(str);
try testing.expectEqualStrings("1\n2\n3\nY\nX", str);
}
}
test "Terminal: index bottom of scroll region no scrollback" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 });
defer t.deinit(alloc);
t.setTopAndBottomMargin(1, 3);
t.setCursorPos(4, 1);
try t.print('B');
@ -6611,11 +6636,6 @@ test "Terminal: index bottom of scroll region" {
try t.index();
try t.print('X');
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 3 } }));
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
@ -6623,6 +6643,47 @@ test "Terminal: index bottom of scroll region" {
}
}
test "Terminal: index bottom of scroll region blank line preserves SGR" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
t.setTopAndBottomMargin(1, 3);
try t.printString("1\n2\n3");
t.setCursorPos(4, 1);
try t.print('X');
t.setCursorPos(3, 1);
try t.setAttribute(.{ .direct_color_bg = .{
.r = 0xFF,
.g = 0,
.b = 0,
} });
try t.index();
{
const str = try t.screen.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer testing.allocator.free(str);
try testing.expectEqualStrings("2\n3\n\nX", str);
}
{
const str = try t.screen.dumpStringAlloc(alloc, .{ .screen = .{} });
defer testing.allocator.free(str);
try testing.expectEqualStrings("1\n2\n3\n\nX", str);
}
for (0..t.cols) |x| {
const list_cell = t.screen.pages.getCell(.{ .active = .{
.x = @intCast(x),
.y = 2,
} }).?;
try testing.expect(list_cell.cell.content_tag == .bg_color_rgb);
try testing.expectEqual(Cell.RGB{
.r = 0xFF,
.g = 0,
.b = 0,
}, list_cell.cell.content.color_rgb);
}
}
test "Terminal: cursorUp basic" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });