diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 003ae6bb9..6eb741af8 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -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; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index a6a044afa..64f7f8499 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -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 });