mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 15:56:13 +03:00
Merge pull request #913 from mitchellh/scroll-and-clear
terminal: add "scroll and clear" (ESC [ 22 J) and associated logic
This commit is contained in:
@ -93,7 +93,7 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
|
|||||||
|
|
||||||
/// Resize the buffer to the given size (larger or smaller).
|
/// Resize the buffer to the given size (larger or smaller).
|
||||||
/// If larger, new values will be set to the default value.
|
/// If larger, new values will be set to the default value.
|
||||||
pub fn resize(self: *Self, alloc: Allocator, size: usize) !void {
|
pub fn resize(self: *Self, alloc: Allocator, size: usize) Allocator.Error!void {
|
||||||
// Rotate to zero so it is aligned.
|
// Rotate to zero so it is aligned.
|
||||||
try self.rotateToZero(alloc);
|
try self.rotateToZero(alloc);
|
||||||
|
|
||||||
@ -116,7 +116,7 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rotate the data so that it is zero-aligned.
|
/// Rotate the data so that it is zero-aligned.
|
||||||
fn rotateToZero(self: *Self, alloc: Allocator) !void {
|
fn rotateToZero(self: *Self, alloc: Allocator) Allocator.Error!void {
|
||||||
// TODO: this does this in the worst possible way by allocating.
|
// TODO: this does this in the worst possible way by allocating.
|
||||||
// rewrite to not allocate, its possible, I'm just lazy right now.
|
// rewrite to not allocate, its possible, I'm just lazy right now.
|
||||||
|
|
||||||
|
@ -1788,13 +1788,22 @@ pub const Scroll = union(enum) {
|
|||||||
/// this will change nothing. If the row is outside the viewport, the
|
/// this will change nothing. If the row is outside the viewport, the
|
||||||
/// viewport will change so that this row is at the top of the viewport.
|
/// viewport will change so that this row is at the top of the viewport.
|
||||||
row: RowIndex,
|
row: RowIndex,
|
||||||
|
|
||||||
|
/// Scroll down and move all viewport contents into the scrollback
|
||||||
|
/// so that the screen is clear. This isn't eqiuivalent to "screen" with
|
||||||
|
/// the value set to the viewport size because this will handle the case
|
||||||
|
/// that the viewport is not full.
|
||||||
|
///
|
||||||
|
/// This will ignore empty trailing rows. An empty row is a row that
|
||||||
|
/// has never been written to at all. A row with spaces is not empty.
|
||||||
|
clear: void,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Scroll the screen by the given behavior. Note that this will always
|
/// Scroll the screen by the given behavior. Note that this will always
|
||||||
/// "move" the screen. It is up to the caller to determine if they actually
|
/// "move" the screen. It is up to the caller to determine if they actually
|
||||||
/// want to do that yet (i.e. are they writing to the end of the screen
|
/// want to do that yet (i.e. are they writing to the end of the screen
|
||||||
/// or not).
|
/// or not).
|
||||||
pub fn scroll(self: *Screen, behavior: Scroll) !void {
|
pub fn scroll(self: *Screen, behavior: Scroll) Allocator.Error!void {
|
||||||
// No matter what, scrolling marks our image state as dirty since
|
// No matter what, scrolling marks our image state as dirty since
|
||||||
// it could move placements. If there are no placements or no images
|
// it could move placements. If there are no placements or no images
|
||||||
// this is still a very cheap operation.
|
// this is still a very cheap operation.
|
||||||
@ -1815,9 +1824,27 @@ pub fn scroll(self: *Screen, behavior: Scroll) !void {
|
|||||||
|
|
||||||
// Scroll to a specific row
|
// Scroll to a specific row
|
||||||
.row => |idx| self.scrollRow(idx),
|
.row => |idx| self.scrollRow(idx),
|
||||||
|
|
||||||
|
// Scroll until the viewport is clear by moving the viewport contents
|
||||||
|
// into the scrollback.
|
||||||
|
.clear => try self.scrollClear(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scrollClear(self: *Screen) Allocator.Error!void {
|
||||||
|
// The full amount of rows in the viewport
|
||||||
|
const full_amount = self.rowsWritten() - self.viewport;
|
||||||
|
|
||||||
|
// Find the number of non-empty rows
|
||||||
|
const non_empty = for (0..full_amount) |i| {
|
||||||
|
const rev_i = full_amount - i - 1;
|
||||||
|
const row = self.getRow(.{ .viewport = rev_i });
|
||||||
|
if (!row.isEmpty()) break rev_i + 1;
|
||||||
|
} else full_amount;
|
||||||
|
|
||||||
|
try self.scroll(.{ .screen = @intCast(non_empty) });
|
||||||
|
}
|
||||||
|
|
||||||
fn scrollRow(self: *Screen, idx: RowIndex) void {
|
fn scrollRow(self: *Screen, idx: RowIndex) void {
|
||||||
// Convert the given row to a screen point.
|
// Convert the given row to a screen point.
|
||||||
const screen_idx = idx.toScreen(self);
|
const screen_idx = idx.toScreen(self);
|
||||||
@ -1830,7 +1857,7 @@ fn scrollRow(self: *Screen, idx: RowIndex) void {
|
|||||||
assert(screen_pt.inViewport(self));
|
assert(screen_pt.inViewport(self));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scrollDelta(self: *Screen, delta: isize, viewport_only: bool) !void {
|
fn scrollDelta(self: *Screen, delta: isize, viewport_only: bool) Allocator.Error!void {
|
||||||
const tracy = trace(@src());
|
const tracy = trace(@src());
|
||||||
defer tracy.end();
|
defer tracy.end();
|
||||||
|
|
||||||
@ -3691,6 +3718,109 @@ test "Screen: scrolling with scrollback available doesn't move selection" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "Screen: scroll and clear full screen" {
|
||||||
|
const testing = std.testing;
|
||||||
|
const alloc = testing.allocator;
|
||||||
|
|
||||||
|
var s = try init(alloc, 3, 5, 5);
|
||||||
|
defer s.deinit();
|
||||||
|
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
|
||||||
|
|
||||||
|
{
|
||||||
|
const contents = try s.testString(alloc, .viewport);
|
||||||
|
defer alloc.free(contents);
|
||||||
|
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
try s.scroll(.{ .clear = {} });
|
||||||
|
{
|
||||||
|
const contents = try s.testString(alloc, .viewport);
|
||||||
|
defer alloc.free(contents);
|
||||||
|
try testing.expectEqualStrings("", contents);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const contents = try s.testString(alloc, .screen);
|
||||||
|
defer alloc.free(contents);
|
||||||
|
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Screen: scroll and clear partial screen" {
|
||||||
|
const testing = std.testing;
|
||||||
|
const alloc = testing.allocator;
|
||||||
|
|
||||||
|
var s = try init(alloc, 3, 5, 5);
|
||||||
|
defer s.deinit();
|
||||||
|
try s.testWriteString("1ABCD\n2EFGH");
|
||||||
|
|
||||||
|
{
|
||||||
|
const contents = try s.testString(alloc, .viewport);
|
||||||
|
defer alloc.free(contents);
|
||||||
|
try testing.expectEqualStrings("1ABCD\n2EFGH", contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
try s.scroll(.{ .clear = {} });
|
||||||
|
{
|
||||||
|
const contents = try s.testString(alloc, .viewport);
|
||||||
|
defer alloc.free(contents);
|
||||||
|
try testing.expectEqualStrings("", contents);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const contents = try s.testString(alloc, .screen);
|
||||||
|
defer alloc.free(contents);
|
||||||
|
try testing.expectEqualStrings("1ABCD\n2EFGH", contents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Screen: scroll and clear empty screen" {
|
||||||
|
const testing = std.testing;
|
||||||
|
const alloc = testing.allocator;
|
||||||
|
|
||||||
|
var s = try init(alloc, 3, 5, 5);
|
||||||
|
defer s.deinit();
|
||||||
|
try s.scroll(.{ .clear = {} });
|
||||||
|
try testing.expectEqual(@as(usize, 0), s.viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Screen: scroll and clear ignore blank lines" {
|
||||||
|
const testing = std.testing;
|
||||||
|
const alloc = testing.allocator;
|
||||||
|
|
||||||
|
var s = try init(alloc, 3, 5, 10);
|
||||||
|
defer s.deinit();
|
||||||
|
try s.testWriteString("1ABCD\n2EFGH");
|
||||||
|
try s.scroll(.{ .clear = {} });
|
||||||
|
{
|
||||||
|
const contents = try s.testString(alloc, .viewport);
|
||||||
|
defer alloc.free(contents);
|
||||||
|
try testing.expectEqualStrings("", contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move back to top-left
|
||||||
|
s.cursor.x = 0;
|
||||||
|
s.cursor.y = 0;
|
||||||
|
|
||||||
|
// Write and clear
|
||||||
|
try s.testWriteString("3ABCD\n");
|
||||||
|
try s.scroll(.{ .clear = {} });
|
||||||
|
{
|
||||||
|
const contents = try s.testString(alloc, .viewport);
|
||||||
|
defer alloc.free(contents);
|
||||||
|
try testing.expectEqualStrings("", contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move back to top-left
|
||||||
|
s.cursor.x = 0;
|
||||||
|
s.cursor.y = 0;
|
||||||
|
try s.testWriteString("X");
|
||||||
|
|
||||||
|
{
|
||||||
|
const contents = try s.testString(alloc, .screen);
|
||||||
|
defer alloc.free(contents);
|
||||||
|
try testing.expectEqualStrings("1ABCD\n2EFGH\n3ABCD\nX", contents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test "Screen: history region with no scrollback" {
|
test "Screen: history region with no scrollback" {
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
const alloc = testing.allocator;
|
const alloc = testing.allocator;
|
||||||
|
@ -1144,7 +1144,57 @@ pub fn eraseDisplay(
|
|||||||
const protected = self.screen.protected_mode == .iso or protected_req;
|
const protected = self.screen.protected_mode == .iso or protected_req;
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
|
.scroll_complete => {
|
||||||
|
self.screen.scroll(.{ .clear = {} }) catch |err| {
|
||||||
|
log.warn("scroll clear failed, doing a normal clear err={}", .{err});
|
||||||
|
self.eraseDisplay(alloc, .complete, protected_req);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unsets pending wrap state
|
||||||
|
self.screen.cursor.pending_wrap = false;
|
||||||
|
|
||||||
|
// Clear all Kitty graphics state for this screen
|
||||||
|
self.screen.kitty_images.delete(alloc, self, .{ .all = true });
|
||||||
|
},
|
||||||
|
|
||||||
.complete => {
|
.complete => {
|
||||||
|
// If we're on the primary screen and our last non-empty row is
|
||||||
|
// a prompt, then we do a scroll_complete instead. This is a
|
||||||
|
// heuristic to get the generally desirable behavior that ^L
|
||||||
|
// at a prompt scrolls the screen contents prior to clearing.
|
||||||
|
// Most shells send `ESC [ H ESC [ 2 J` so we can't just check
|
||||||
|
// our current cursor position. See #905
|
||||||
|
if (self.active_screen == .primary) at_prompt: {
|
||||||
|
// Go from the bottom of the viewport up and see if we're
|
||||||
|
// at a prompt.
|
||||||
|
const viewport_max = Screen.RowIndexTag.viewport.maxLen(&self.screen);
|
||||||
|
for (0..viewport_max) |y| {
|
||||||
|
const bottom_y = viewport_max - y - 1;
|
||||||
|
const row = self.screen.getRow(.{ .viewport = bottom_y });
|
||||||
|
if (row.isEmpty()) continue;
|
||||||
|
switch (row.getSemanticPrompt()) {
|
||||||
|
// If we're at a prompt or input area, then we are at a prompt.
|
||||||
|
.prompt,
|
||||||
|
.prompt_continuation,
|
||||||
|
.input,
|
||||||
|
=> break,
|
||||||
|
|
||||||
|
// If we have command output, then we're most certainly not
|
||||||
|
// at a prompt.
|
||||||
|
.command => break :at_prompt,
|
||||||
|
|
||||||
|
// If we don't know, we keep searching.
|
||||||
|
.unknown => {},
|
||||||
|
}
|
||||||
|
} else break :at_prompt;
|
||||||
|
|
||||||
|
self.screen.scroll(.{ .clear = {} }) catch {
|
||||||
|
// If we fail, we just fall back to doing a normal clear
|
||||||
|
// so we don't worry about the error.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var it = self.screen.rowIterator(.active);
|
var it = self.screen.rowIterator(.active);
|
||||||
while (it.next()) |row| {
|
while (it.next()) |row| {
|
||||||
row.setWrapped(false);
|
row.setWrapped(false);
|
||||||
@ -6017,6 +6067,23 @@ test "Terminal: eraseDisplay protected above" {
|
|||||||
var t = try init(alloc, 10, 5);
|
var t = try init(alloc, 10, 5);
|
||||||
defer t.deinit(alloc);
|
defer t.deinit(alloc);
|
||||||
|
|
||||||
|
try t.print('A');
|
||||||
|
t.carriageReturn();
|
||||||
|
try t.linefeed();
|
||||||
|
t.eraseDisplay(alloc, .scroll_complete, false);
|
||||||
|
|
||||||
|
{
|
||||||
|
const str = try t.plainString(testing.allocator);
|
||||||
|
defer testing.allocator.free(str);
|
||||||
|
try testing.expectEqualStrings("", str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Terminal: eraseDisplay scroll complete" {
|
||||||
|
const alloc = testing.allocator;
|
||||||
|
var t = try init(alloc, 10, 3);
|
||||||
|
defer t.deinit(alloc);
|
||||||
|
|
||||||
try t.print('A');
|
try t.print('A');
|
||||||
t.carriageReturn();
|
t.carriageReturn();
|
||||||
try t.linefeed();
|
try t.linefeed();
|
||||||
|
@ -4,6 +4,10 @@ pub const EraseDisplay = enum(u8) {
|
|||||||
above = 1,
|
above = 1,
|
||||||
complete = 2,
|
complete = 2,
|
||||||
scrollback = 3,
|
scrollback = 3,
|
||||||
|
|
||||||
|
/// This is an extension added by Kitty to move the viewport into the
|
||||||
|
/// scrollback and then erase the display.
|
||||||
|
scroll_complete = 22,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Modes for the EL CSI command.
|
// Modes for the EL CSI command.
|
||||||
|
@ -294,7 +294,10 @@ pub fn Stream(comptime Handler: type) type {
|
|||||||
|
|
||||||
const mode_: ?csi.EraseDisplay = switch (action.params.len) {
|
const mode_: ?csi.EraseDisplay = switch (action.params.len) {
|
||||||
0 => .below,
|
0 => .below,
|
||||||
1 => if (action.params[0] <= 3) @enumFromInt(action.params[0]) else null,
|
1 => if (action.params[0] <= 3)
|
||||||
|
std.meta.intToEnum(csi.EraseDisplay, action.params[0]) catch null
|
||||||
|
else
|
||||||
|
null,
|
||||||
else => null,
|
else => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user