mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-10 11:19:45 +00:00
terminal: handful of scroll region optimizations (minor) (#13231)
This optimizes scrolling inside a scroll region (DECSTBM). We're squeezing blood out of a rock here. There just isn't much improvement to be had given our architecture for this case, but there were a couple things found that result in modest gains. ## Vtebench You can see the modest gains here. Still behind Alacritty (very very close). <img width="3558" height="2410" alt="CleanShot 2026-07-06 at 21 18 39@2x" src="https://github.com/user-attachments/assets/3c800089-3510-4887-9872-e9926d686955" /> ## The changes 1. **Stop creating scrollback for top-anchored regions on screens that don't retain scrollback.** `index()` routed any full-width region with `top == 0` through `cursorScrollAbove()`, which pushes the scrolled-out row into scrollback. Every scroll paid `PageList.grow()` plus amortized page pruning, which includes a 512 KB `memset` each time a page is recycled. These now use the in-place region scroll instead. CSI S gets the same routing fix. **Result: 1.05x-1.49x on the bottom-anchored region workloads, 1.25x on alt-screen full-screen scrolling.** 2. **Add a specialized `Screen.cursorScrollRegionUp()` for the region scroll hot path.** The previous fast path (`PageList.eraseRowBounded`) paid per-scroll bookkeeping that exceeded the actual row work. The new function is built around the invariant that the cursor sits on the bottom row of a full-width region. **Result: 1.23x-1.24x on the top-anchored region workloads.** ## Benchmarks | workload | region (80 rows) | before | after | change | |---|---|---|---|---| | scrolling (control) | primary screen, no region | 237 ms | 235 ms | 1.0x | | scrolling_bottom_region | alt, rows 1-79 | 243 ms | 231 ms | 1.05x | | scrolling_bottom_small_region | alt, rows 1-40 | 311 ms | 208 ms | 1.49x | | scrolling_top_region | alt, rows 2-80 | 283 ms | 229 ms | 1.23x | | scrolling_top_small_region | alt, rows 40-80 | 258 ms | 208 ms | 1.24x | | alt screen full-screen scrolling | alt, no region | 288 ms | 230 ms | 1.25x | ## LLM Notes Assisted by Fable 5: it diagnosed the vtebench gap, wrote the benchmark harness payloads, profiled, and proposed hot paths. I manually wrote the hot path replacements and had it judge my work.
This commit is contained in:
@@ -1055,6 +1055,155 @@ fn cursorScrollAboveRotate(self: *Screen) !void {
|
||||
self.cursor.page_cell = page_rac.cell;
|
||||
}
|
||||
|
||||
/// Scroll a full-width scroll region that ends at the cursor row up by
|
||||
/// one row. The cursor must be on the bottom row of the region. `limit`
|
||||
/// is the number of rows in the region above the cursor (region height
|
||||
/// minus one) and must be at least 1.
|
||||
///
|
||||
/// The top row of the region is discarded (NOT moved into scrollback).
|
||||
/// All other rows in the region shift up by one and the cursor row
|
||||
/// becomes a blank row, filled with the current background color (like
|
||||
/// other scroll operations such as deleteLines).
|
||||
///
|
||||
/// The cursor stays at the same screen position (the new blank row).
|
||||
/// Content outside of the region is unmodified.
|
||||
///
|
||||
/// This is a very hot path for scroll region usage (e.g. a program
|
||||
/// on the alt screen using DECSTBM and scrolling via LF/IND) so this
|
||||
/// is optimized for the common case where the full region is within
|
||||
/// a single page.
|
||||
pub fn cursorScrollRegionUp(self: *Screen, limit: usize) !void {
|
||||
assert(limit >= 1);
|
||||
assert(self.cursor.y >= limit);
|
||||
defer self.assertIntegrity();
|
||||
|
||||
const pin: *Pin = self.cursor.page_pin;
|
||||
|
||||
// If the region crosses a page boundary we take a slower path. This
|
||||
// is rare: it requires the active area to span multiple pages with
|
||||
// the split point inside the scroll region.
|
||||
if (pin.y < limit) return try self.cursorScrollRegionUpSlow(limit);
|
||||
|
||||
// Fast path: the entire region is in a single page. We can clear
|
||||
// the top row and rotate it down to the cursor row, updating any
|
||||
// tracked pins along the way.
|
||||
const page: *Page = &pin.node.data;
|
||||
const rows = page.rows.ptr(page.memory.ptr)[pin.y - limit ..][0 .. limit + 1];
|
||||
|
||||
// Clear the erased (top) row.
|
||||
{
|
||||
const row: *Row = &rows[0];
|
||||
|
||||
// Whether our blank cell is a plain zero cell. This is true
|
||||
// unless the cursor has a background color set (see blankCell).
|
||||
const blank_is_zero = self.cursor.style_id == style.default_id or
|
||||
self.cursor.style.bg_color == .none;
|
||||
|
||||
if (!row.managedMemory() and blank_is_zero) {
|
||||
// Hot path: the row has no managed memory (styles,
|
||||
// graphemes, hyperlinks) and our blank is zero so this is
|
||||
// a straight zero fill. This is the overwhelmingly common
|
||||
// case for scroll region usage.
|
||||
const cells = page.getCells(row);
|
||||
@memset(@as([]u64, @ptrCast(cells)), 0);
|
||||
} else {
|
||||
// The generic clear handles managed memory and fills the
|
||||
// row with our blank cell, preserving the background color.
|
||||
self.clearCells(page, row, page.getCells(row));
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate the region rows so the now-blank top row moves to the
|
||||
// bottom (the cursor row) and everything else shifts up by one.
|
||||
fastmem.rotateOnce(Row, rows);
|
||||
|
||||
// Mark the whole page as dirty.
|
||||
//
|
||||
// Technically we only need to mark the rotated rows but this is
|
||||
// a hot function, so we want to minimize work.
|
||||
page.dirty = true;
|
||||
|
||||
// If our viewport is a pin and it's within the rotated region
|
||||
// then we need to shift its cached row offset up. See
|
||||
// PageList.eraseRowBounded for details; this mirrors that logic.
|
||||
if (self.pages.viewport == .pin) viewport: {
|
||||
if (self.pages.viewport_pin_row_offset) |*v| {
|
||||
const p = self.pages.viewport_pin;
|
||||
if (p.node != pin.node or
|
||||
p.y < pin.y - limit or
|
||||
p.y > pin.y or
|
||||
p.y == 0) break :viewport;
|
||||
v.* -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Update tracked pins within the region since their rows moved up
|
||||
// by one. The cursor's own pin is skipped because the cursor stays
|
||||
// at the region bottom (the new blank row).
|
||||
const pin_keys = self.pages.tracked_pins.keys();
|
||||
for (pin_keys) |p| {
|
||||
if (p.node != pin.node or
|
||||
p == pin or
|
||||
p.y < pin.y - limit or
|
||||
p.y > pin.y) continue;
|
||||
if (p.y == 0) p.x = 0 else p.y -= 1;
|
||||
}
|
||||
|
||||
// The cursor pin is unchanged, but the Row structure at the pin
|
||||
// position now contains the blank row, so we need to refresh our
|
||||
// cached row/cell pointers. We compute them directly from the row
|
||||
// slice we already have rather than going through the pin since
|
||||
// this is a hot path.
|
||||
const cursor_row: *Row = &rows[limit];
|
||||
self.cursor.page_row = cursor_row;
|
||||
self.cursor.page_cell = &page.getCells(cursor_row)[self.cursor.x];
|
||||
}
|
||||
|
||||
/// Slow path for cursorScrollRegionUp: the scroll region spans
|
||||
/// multiple pages so we use the generic PageList erase machinery.
|
||||
fn cursorScrollRegionUpSlow(self: *Screen, limit: usize) !void {
|
||||
// The call to eraseRowBounded below will move our tracked cursor
|
||||
// pin up by one row since it is inside the erased region, but we
|
||||
// don't actually want that: the cursor stays put, on the new blank
|
||||
// row at the region bottom. We keep the old pin and put it back
|
||||
// after, exactly like cursorDownScroll does for the no-scrollback
|
||||
// case.
|
||||
//
|
||||
// This matters beyond performance: when the cursor is on the first
|
||||
// row of a page, eraseRowBounded moves the tracked pin to the
|
||||
// previous page. Moving the cursor back down with cursorDown would
|
||||
// then cross pages via cursorChangePin, which migrates the cursor
|
||||
// style refcount from a page that never held it, corrupting the
|
||||
// style accounting.
|
||||
const old_pin = self.cursor.page_pin.*;
|
||||
|
||||
try self.pages.eraseRowBounded(
|
||||
.{ .active = .{ .y = @intCast(self.cursor.y - limit) } },
|
||||
limit,
|
||||
);
|
||||
|
||||
// We don't use `cursorChangePin` here because we aren't actually
|
||||
// changing the pin, we're keeping it the same. Since the page
|
||||
// never changes, the cursor's style ref stays valid and no style
|
||||
// accounting needs to be updated.
|
||||
self.cursor.page_pin.* = old_pin;
|
||||
|
||||
// We do, however, need to refresh the cached page row and cell,
|
||||
// because the row contents at our pin position changed (it now
|
||||
// contains the blank row).
|
||||
const page_rac = self.cursor.page_pin.rowAndCell();
|
||||
self.cursor.page_row = page_rac.row;
|
||||
self.cursor.page_cell = page_rac.cell;
|
||||
|
||||
// eraseRowBounded clears the new row with zero cells so if our
|
||||
// blank cell isn't zero (bg color is set) we need to fill it.
|
||||
const blank = self.blankCell();
|
||||
if (!blank.isZero()) {
|
||||
const cells: [*]Cell = @ptrCast(self.cursor.page_cell);
|
||||
@memset((cells - self.cursor.x)[0..self.pages.cols], blank);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor down if we're not at the bottom of the screen. Otherwise
|
||||
/// scroll. Currently only used for testing.
|
||||
inline fn cursorDownOrScroll(self: *Screen) !void {
|
||||
@@ -4589,6 +4738,207 @@ test "Screen: scrolling moves selection" {
|
||||
}
|
||||
}
|
||||
|
||||
test "Screen: cursorScrollRegionUp simple" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
|
||||
|
||||
// Scroll a region ending at row 2 (zero-indexed) up by one. This
|
||||
// emulates a scroll region of rows 0-2 with the cursor at the
|
||||
// region bottom.
|
||||
s.cursorAbsolute(1, 2);
|
||||
try s.cursorScrollRegionUp(2);
|
||||
|
||||
// The cursor stays in place, on the new blank row.
|
||||
try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
|
||||
|
||||
// Rows in the region scrolled, rows below are unchanged, and
|
||||
// nothing was moved into scrollback.
|
||||
{
|
||||
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
|
||||
defer alloc.free(contents);
|
||||
try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents);
|
||||
}
|
||||
}
|
||||
|
||||
test "Screen: cursorScrollRegionUp moves selection" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
|
||||
|
||||
// Select the second row.
|
||||
try s.select(Selection.init(
|
||||
s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
|
||||
s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?,
|
||||
false,
|
||||
));
|
||||
|
||||
s.cursorAbsolute(0, 2);
|
||||
try s.cursorScrollRegionUp(2);
|
||||
|
||||
// Our selection should've moved up with its row.
|
||||
{
|
||||
const sel = s.selection.?;
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = s.pages.cols - 1,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
}
|
||||
|
||||
{
|
||||
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
|
||||
defer alloc.free(contents);
|
||||
try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents);
|
||||
}
|
||||
}
|
||||
|
||||
test "Screen: cursorScrollRegionUp region spans pages" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 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;
|
||||
s.pages.pages.first.?.data.pauseIntegrityChecks(true);
|
||||
for (0..first_page_size - 3) |_| try s.testWriteString("\n");
|
||||
s.pages.pages.first.?.data.pauseIntegrityChecks(false);
|
||||
try s.testWriteString("1A\n2B\n3C\n4D\n5E");
|
||||
|
||||
// At this point:
|
||||
// +----------+ = PAGE 0
|
||||
// ... : :
|
||||
// +-------------+ ACTIVE
|
||||
// 4305 |1A00000000| | 0
|
||||
// 4306 |2B00000000| | 1
|
||||
// 4307 |3C00000000| | 2
|
||||
// +----------+ :
|
||||
// +----------+ : = PAGE 1
|
||||
// 0 |4D00000000| | 3
|
||||
// :^ : : = PIN 0
|
||||
// 1 |5E00000000| | 4
|
||||
// +----------+ :
|
||||
// +-------------+
|
||||
|
||||
// Move the cursor to the first row of the second page and give it
|
||||
// a non-default style. This is important: it verifies that the
|
||||
// cursor's style ref stays accounted on the correct page even
|
||||
// though eraseRowBounded moves the cursor's tracked pin across
|
||||
// the page boundary.
|
||||
s.cursorAbsolute(0, 3);
|
||||
try s.setAttribute(.{ .bold = {} });
|
||||
try testing.expect(s.cursor.page_pin.node == s.pages.pages.last.?);
|
||||
try testing.expectEqual(@as(usize, 0), s.cursor.page_pin.y);
|
||||
|
||||
// Scroll a region of active rows 1-3 with the cursor at the region
|
||||
// bottom. The region spans the page boundary so this exercises the
|
||||
// slow path.
|
||||
try s.cursorScrollRegionUp(2);
|
||||
|
||||
// The cursor stays in place, on the new blank row.
|
||||
try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.y);
|
||||
|
||||
{
|
||||
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
|
||||
defer alloc.free(contents);
|
||||
try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents);
|
||||
}
|
||||
|
||||
// Our cursor style must remain usable: write a styled cell and
|
||||
// verify the style ref counting is intact on the cursor's page.
|
||||
try s.testWriteString("X");
|
||||
{
|
||||
const page = &s.cursor.page_pin.node.data;
|
||||
const styles = page.styles.count();
|
||||
try testing.expectEqual(@as(usize, 1), styles);
|
||||
}
|
||||
}
|
||||
|
||||
test "Screen: cursorScrollRegionUp region spans pages with background SGR" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 });
|
||||
defer s.deinit();
|
||||
|
||||
// We need to get the cursor to a new page. See the previous test
|
||||
// for a diagram of the page layout.
|
||||
const first_page_size = s.pages.pages.first.?.data.capacity.rows;
|
||||
s.pages.pages.first.?.data.pauseIntegrityChecks(true);
|
||||
for (0..first_page_size - 3) |_| try s.testWriteString("\n");
|
||||
s.pages.pages.first.?.data.pauseIntegrityChecks(false);
|
||||
try s.testWriteString("1A\n2B\n3C\n4D\n5E");
|
||||
|
||||
s.cursorAbsolute(0, 3);
|
||||
try s.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } });
|
||||
try testing.expect(s.cursor.page_pin.node == s.pages.pages.last.?);
|
||||
try testing.expectEqual(@as(usize, 0), s.cursor.page_pin.y);
|
||||
|
||||
try s.cursorScrollRegionUp(2);
|
||||
|
||||
{
|
||||
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
|
||||
defer alloc.free(contents);
|
||||
try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents);
|
||||
}
|
||||
|
||||
// The new blank row must be filled with our background color.
|
||||
for (0..s.pages.cols) |x| {
|
||||
const list_cell = s.pages.getCell(.{ .active = .{
|
||||
.x = @intCast(x),
|
||||
.y = 3,
|
||||
} }).?;
|
||||
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 "Screen: cursorScrollRegionUp with styled erased row" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 });
|
||||
defer s.deinit();
|
||||
|
||||
// Write a styled row at the top so the erased row has managed
|
||||
// memory that must be released.
|
||||
try s.setAttribute(.{ .bold = {} });
|
||||
try s.testWriteString("1ABCD");
|
||||
try s.setAttribute(.{ .unset = {} });
|
||||
try s.testWriteString("\n2EFGH\n3IJKL");
|
||||
|
||||
s.cursorAbsolute(0, 2);
|
||||
try s.cursorScrollRegionUp(2);
|
||||
|
||||
{
|
||||
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
|
||||
defer alloc.free(contents);
|
||||
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
|
||||
}
|
||||
|
||||
// The style should be gone from the page since the only user
|
||||
// was the erased row.
|
||||
const page = &s.cursor.page_pin.node.data;
|
||||
try testing.expectEqual(@as(usize, 0), page.styles.count());
|
||||
}
|
||||
|
||||
test "Screen: scrolling moves viewport" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@@ -1974,10 +1974,19 @@ pub fn index(self: *Terminal) !void {
|
||||
screen.kitty_images.dirty = true;
|
||||
}
|
||||
|
||||
// If our scrolling region is at the top, we create scrollback.
|
||||
// If our scrolling region is at the top, we create scrollback,
|
||||
// but only if our screen retains scrollback. If our screen
|
||||
// doesn't retain scrollback (e.g. the alternate screen) then
|
||||
// creating scrollback is pure overhead: the rows are never
|
||||
// visible and are simply pruned later. In that case we use the
|
||||
// in-place region scroll below, unless the region is a single
|
||||
// row (a one row screen) which cursorScrollRegionUp can't
|
||||
// handle (and cursorDownScroll special-cases).
|
||||
if (self.scrolling_region.top == 0 and
|
||||
self.scrolling_region.left == 0 and
|
||||
self.scrolling_region.right == self.cols - 1)
|
||||
self.scrolling_region.right == self.cols - 1 and
|
||||
(!screen.no_scrollback or
|
||||
self.scrolling_region.bottom == 0))
|
||||
{
|
||||
try screen.cursorScrollAbove();
|
||||
return;
|
||||
@@ -1985,49 +1994,18 @@ pub fn index(self: *Terminal) !void {
|
||||
|
||||
// Slow path for left and right scrolling region margins.
|
||||
if (self.scrolling_region.left != 0 or
|
||||
self.scrolling_region.right != self.cols - 1 or
|
||||
|
||||
// PERF(mitchellh): If we have an SGR background set then
|
||||
// we need to preserve that background in our erased rows.
|
||||
// scrollUp does that but eraseRowBounded below does not.
|
||||
// However, scrollUp is WAY slower. We should optimize this
|
||||
// case to work in the eraseRowBounded codepath and remove
|
||||
// this check.
|
||||
!screen.blankCell().isZero())
|
||||
self.scrolling_region.right != self.cols - 1)
|
||||
{
|
||||
try self.scrollUp(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise use a fast path function from PageList to efficiently
|
||||
// scroll the contents of the scrolling region.
|
||||
|
||||
// Preserve old cursor just for assertions
|
||||
const old_cursor = screen.cursor;
|
||||
|
||||
try screen.pages.eraseRowBounded(
|
||||
.{ .active = .{ .y = self.scrolling_region.top } },
|
||||
// Otherwise use a fast path function to efficiently scroll
|
||||
// the contents of the scrolling region.
|
||||
try screen.cursorScrollRegionUp(
|
||||
self.scrolling_region.bottom - self.scrolling_region.top,
|
||||
);
|
||||
|
||||
// eraseRow and eraseRowBounded will end up moving the cursor pin
|
||||
// up by 1, so we need to move it back down. A `cursorReload`
|
||||
// would be better option but this is more efficient and this is
|
||||
// a super hot path so we do this instead.
|
||||
assert(screen.cursor.x == old_cursor.x);
|
||||
assert(screen.cursor.y == old_cursor.y);
|
||||
screen.cursor.y -= 1;
|
||||
screen.cursorDown(1);
|
||||
|
||||
// The operations above can prune our cursor style so we need to
|
||||
// update. This should never fail because the above can only FREE
|
||||
// memory.
|
||||
screen.manualStyleUpdate() catch |err| {
|
||||
std.log.warn("deleteLines manualStyleUpdate err={}", .{err});
|
||||
screen.cursor.style = .{};
|
||||
screen.manualStyleUpdate() catch unreachable;
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2186,9 +2164,17 @@ pub fn scrollUp(self: *Terminal, count: usize) !void {
|
||||
|
||||
// If our scroll region is at the top and we have no left/right
|
||||
// margins then we move the scrolled out text into the scrollback.
|
||||
//
|
||||
// If our screen doesn't retain scrollback (e.g. the alternate
|
||||
// screen) then creating scrollback is pure overhead, so we use the
|
||||
// deleteLines path below instead, unless the region is the full
|
||||
// screen where cursorScrollAbove has a specialized fast path
|
||||
// (cursorDownScroll) for scrolling without scrollback.
|
||||
if (self.scrolling_region.top == 0 and
|
||||
self.scrolling_region.left == 0 and
|
||||
self.scrolling_region.right == self.cols - 1)
|
||||
self.scrolling_region.right == self.cols - 1 and
|
||||
(!self.screens.active.no_scrollback or
|
||||
self.scrolling_region.bottom == self.rows - 1))
|
||||
{
|
||||
// Scrolling dirties the images because it updates their placements pins.
|
||||
if (comptime build_options.kitty_graphics) {
|
||||
@@ -8940,6 +8926,128 @@ test "Terminal: index bottom of scroll region blank line preserves SGR" {
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: index bottom of scroll region with top margin and background SGR" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
try t.printString("1\n2\n3\n4\n5");
|
||||
t.setTopAndBottomMargin(2, 4);
|
||||
t.setCursorPos(4, 1);
|
||||
try t.setAttribute(.{ .direct_color_bg = .{
|
||||
.r = 0xFF,
|
||||
.g = 0,
|
||||
.b = 0,
|
||||
} });
|
||||
try t.index();
|
||||
|
||||
// The region (rows 2-4) scrolled up, rows outside are unchanged.
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("1\n3\n4\n\n5", str);
|
||||
}
|
||||
|
||||
// The cursor is on the new blank row.
|
||||
try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.y);
|
||||
|
||||
// The new blank row must be filled with our background color.
|
||||
for (0..t.cols) |x| {
|
||||
const list_cell = t.screens.active.pages.getCell(.{ .active = .{
|
||||
.x = @intCast(x),
|
||||
.y = 3,
|
||||
} }).?;
|
||||
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: index bottom of alt screen full region" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .rows = 3, .cols = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
try t.switchScreenMode(.@"1049", true);
|
||||
try t.printString("A\nB\nC");
|
||||
try t.index();
|
||||
t.carriageReturn();
|
||||
try t.print('D');
|
||||
|
||||
// Content scrolled up and the scrolled-out row is discarded, NOT
|
||||
// moved into scrollback (the alt screen has none).
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("B\nC\nD", str);
|
||||
}
|
||||
{
|
||||
const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("B\nC\nD", str);
|
||||
}
|
||||
|
||||
// Primary screen is untouched.
|
||||
try t.switchScreenMode(.@"1049", false);
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("", str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: index bottom of alt screen top region" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
try t.switchScreenMode(.@"1049", true);
|
||||
try t.printString("1\n2\n3\n4\n5");
|
||||
|
||||
// Region at the top of the screen, excluding the last row. On the
|
||||
// alt screen this must NOT create scrollback.
|
||||
t.setTopAndBottomMargin(1, 4);
|
||||
t.setCursorPos(4, 1);
|
||||
try t.index();
|
||||
try t.print('X');
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("2\n3\n4\nX\n5", str);
|
||||
}
|
||||
{
|
||||
const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("2\n3\n4\nX\n5", str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: scrollUp top region no scrollback" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
try t.printString("A\nB\nC\nD\nE");
|
||||
t.setTopAndBottomMargin(1, 3);
|
||||
try t.scrollUp(1);
|
||||
|
||||
// The region scrolled and the scrolled-out row is discarded.
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("B\nC\n\nD\nE", str);
|
||||
}
|
||||
{
|
||||
const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("B\nC\n\nD\nE", str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: cursorUp basic" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
|
||||
|
||||
Reference in New Issue
Block a user