mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-31 04:39:01 +00:00
terminal: make resize handling error-safe, handle more logic (#13367)
This makes error returns in `Screen.resize` and `Terminal.resize` safe: they leave the terminal/screen in its prior state (with one exception, noted below). Previously, both were documented as leaving the terminal in a garbage stage on error. Unit tests with tripwire added to verify errdefer behaviors. This also includes a refactor: the common behaviors that resize needs such as updating cell size in pixels, disabling synchronized output, handling mode 2048, are now pulled into libghostty-vt. This will make it easier for downstream libghostty users to make proper terminals. **The one perfect undo exception:** If the primary screen can resize but the alt screen cannot, then we try our best to do something reasonable, in order: 1. If we're on the primary screen, we just deallocate the alt screen. It'll be reallocated lazily (and may fail) in the future. Worst case here is we lose screen data if the future TUI doesn't expect a clear on enter/exit. 2. If we're on the alt screen, we deallocate to try recover memory, then reinitialize eagerly at the new size hoping to at least have a blank alt screen. Similar to 1, we lose screen data here, but its likely screen data that mattered since we were actively on the alt screen. 3. If the eager reinit fails, we switch to the primary screen. This is the biggest issue, cause the TUI program won't know this happened and probably do some crazy stuff on the primary screen. But, its also a super exceptional situation. In every case though, the terminal is consistent and safe to use. **LLM notes:** Only used as a judge and to assist with test writing, not used at all for commit messages, PR, or non-test logic.
This commit is contained in:
@@ -1851,50 +1851,80 @@ pub const Resize = struct {
|
||||
prompt_redraw: osc.semantic_prompt.Redraw = .false,
|
||||
};
|
||||
|
||||
/// Resize the screen. The rows or cols can be bigger or smaller.
|
||||
///
|
||||
/// If this returns an error, the screen is left in a likely garbage state.
|
||||
/// It is very hard to undo this operation without blowing up our memory
|
||||
/// usage. The only way to recover is to reset the screen. The only way
|
||||
/// this really fails is if page allocation is required and fails, which
|
||||
/// probably means the system is in trouble anyways. I'd like to improve this
|
||||
/// in the future but it is not a priority particularly because this scenario
|
||||
/// (resize) is difficult.
|
||||
const resize_tw = tripwire.module(enum {
|
||||
saved_cursor_pin,
|
||||
pages,
|
||||
}, resize);
|
||||
|
||||
/// Resize the screen. The rows or cols can be bigger or smaller. If this
|
||||
/// returns an error, the screen is unchanged.
|
||||
pub inline fn resize(
|
||||
self: *Screen,
|
||||
opts: Resize,
|
||||
) !void {
|
||||
) Allocator.Error!void {
|
||||
const tw = resize_tw;
|
||||
defer self.assertIntegrity();
|
||||
|
||||
if (comptime build_options.kitty_graphics) {
|
||||
// No matter what we mark our image state as dirty
|
||||
self.kitty_images.dirty = true;
|
||||
// We need to insert a tracked pin for our saved cursor so we can
|
||||
// modify its X/Y for reflow. Do this before changing any state since
|
||||
// tracking the pin can fail.
|
||||
const saved_cursor_pin: ?*Pin = saved_cursor: {
|
||||
const sc = self.saved_cursor orelse break :saved_cursor null;
|
||||
const pin = self.pages.pin(.{ .active = .{
|
||||
.x = sc.x,
|
||||
.y = sc.y,
|
||||
} }) orelse break :saved_cursor null;
|
||||
try tw.check(.saved_cursor_pin);
|
||||
break :saved_cursor try self.pages.trackPin(pin);
|
||||
};
|
||||
defer if (saved_cursor_pin) |p| self.pages.untrackPin(p);
|
||||
|
||||
// A cursor style and hyperlink are partly stored in the page containing
|
||||
// the cursor. Their IDs only have meaning within that page, and the page
|
||||
// keeps reference counts for them. Resizing can replace or destroy the
|
||||
// page, so below we temporarily remove both values from the cursor before
|
||||
// asking PageList to resize.
|
||||
//
|
||||
// Save everything first so we can put the cursor back together afterward,
|
||||
// or restore it unchanged if the resize fails. We also save the original
|
||||
// node and serial so after a successful resize we can tell whether that
|
||||
// page survived and still needs its temporary references released.
|
||||
const cursor_node = self.cursor.page_pin.node;
|
||||
const cursor_node_serial = cursor_node.serial;
|
||||
const cursor_style = self.cursor.style;
|
||||
const cursor_style_id = self.cursor.style_id;
|
||||
const cursor_hyperlink = self.cursor.hyperlink;
|
||||
const cursor_hyperlink_id = self.cursor.hyperlink_id;
|
||||
|
||||
// Keep an extra reference to the cursor style and hyperlink while the
|
||||
// resize is in progress. Releasing the cursor references below therefore
|
||||
// can't delete either entry, and an error can restore the cursor without
|
||||
// any allocation.
|
||||
{
|
||||
const page = cursor_node.page();
|
||||
if (cursor_style_id != style.default_id) {
|
||||
page.styles.use(page.memory, cursor_style_id);
|
||||
}
|
||||
if (cursor_hyperlink_id != 0) {
|
||||
page.hyperlink_set.use(page.memory, cursor_hyperlink_id);
|
||||
}
|
||||
}
|
||||
errdefer {
|
||||
self.cursor.style = cursor_style;
|
||||
self.cursor.style_id = cursor_style_id;
|
||||
self.cursor.hyperlink = cursor_hyperlink;
|
||||
self.cursor.hyperlink_id = cursor_hyperlink_id;
|
||||
}
|
||||
|
||||
// Release the cursor style while resizing just
|
||||
// in case the cursor ends up on a different page.
|
||||
const cursor_style = self.cursor.style;
|
||||
// Release the cursor style while resizing just in case the cursor ends
|
||||
// up on a different page.
|
||||
self.cursor.style = .{};
|
||||
self.manualStyleUpdate() catch unreachable;
|
||||
defer {
|
||||
// Restore the cursor style.
|
||||
self.cursor.style = cursor_style;
|
||||
self.manualStyleUpdate() catch |err| {
|
||||
// This failure should not happen because manualStyleUpdate
|
||||
// handles page splitting, overflow, and more. This should only
|
||||
// happen if we're out of RAM. In this case, we'll just degrade
|
||||
// gracefully back to the default style.
|
||||
log.err("failed to update style on cursor reload err={}", .{err});
|
||||
self.cursor.style = .{};
|
||||
self.cursor.style_id = 0;
|
||||
};
|
||||
}
|
||||
|
||||
// If we have a hyperlink, release it from the old page
|
||||
// and then we need to re-add it to the new page. This needs
|
||||
// to happen because resize below typically reallocates a
|
||||
// new page so the old hyperlink is invalid.
|
||||
const hyperlink_ = self.cursor.hyperlink;
|
||||
if (self.cursor.hyperlink_id != 0) {
|
||||
// Note we do NOT use endHyperlink because we want to keep
|
||||
// our allocated self.cursor.hyperlink valid.
|
||||
@@ -1904,20 +1934,138 @@ pub inline fn resize(
|
||||
self.cursor.hyperlink = null;
|
||||
}
|
||||
|
||||
// We need to insert a tracked pin for our saved cursor so we can
|
||||
// modify its X/Y for reflow.
|
||||
const saved_cursor_pin: ?*Pin = saved_cursor: {
|
||||
const sc = self.saved_cursor orelse break :saved_cursor null;
|
||||
const pin = self.pages.pin(.{ .active = .{
|
||||
.x = sc.x,
|
||||
.y = sc.y,
|
||||
} }) orelse break :saved_cursor null;
|
||||
break :saved_cursor try self.pages.trackPin(pin);
|
||||
};
|
||||
defer if (saved_cursor_pin) |p| self.pages.untrackPin(p);
|
||||
// Perform the resize operation.
|
||||
try tw.check(.pages);
|
||||
try self.pages.resize(.{
|
||||
.rows = opts.rows,
|
||||
.cols = opts.cols,
|
||||
.reflow = opts.reflow,
|
||||
.cursor = .{
|
||||
.x = self.cursor.x,
|
||||
.y = self.cursor.y,
|
||||
.pin = self.cursor.page_pin,
|
||||
},
|
||||
});
|
||||
|
||||
// No more failures are possible after this. Enforced by compiler
|
||||
// because we do NO cleanup of the state below.
|
||||
errdefer comptime unreachable;
|
||||
|
||||
// Resizing moves image placements, so mark their geometry as dirty.
|
||||
if (comptime build_options.kitty_graphics) self.kitty_images.dirty = true;
|
||||
|
||||
// If we have no scrollback and we shrunk our rows, we must explicitly
|
||||
// erase our history. This is because PageList always keeps at least
|
||||
// a page size of history.
|
||||
if (self.no_scrollback) self.pages.eraseHistory(null);
|
||||
|
||||
// If our cursor was updated, we do a full reload so all our cursor
|
||||
// state is correct.
|
||||
self.cursorReload();
|
||||
|
||||
// Clear any redrawable prompt after the fallible resize but before
|
||||
// restoring the cursor style and hyperlink, so cleared cells retain the
|
||||
// same default styling they had with the previous ordering.
|
||||
self.clearPromptForRedraw(opts.prompt_redraw);
|
||||
|
||||
// Restore the cursor style.
|
||||
self.cursor.style = cursor_style;
|
||||
self.manualStyleUpdate() catch |err| {
|
||||
// This failure should not happen because manualStyleUpdate handles
|
||||
// page splitting, overflow, and more. This should only happen if
|
||||
// we're out of RAM. In this case, we'll just degrade gracefully back
|
||||
// to the default style.
|
||||
log.err("failed to update style on cursor reload err={}", .{err});
|
||||
self.cursor.style = .{};
|
||||
self.cursor.style_id = 0;
|
||||
};
|
||||
|
||||
// If we reflowed a saved cursor, update it.
|
||||
if (saved_cursor_pin) |p| {
|
||||
// This should never fail because a non-null saved_cursor_pin
|
||||
// implies a non-null saved_cursor.
|
||||
const sc = &self.saved_cursor.?;
|
||||
if (self.pages.pointFromPin(.active, p.*)) |pt| {
|
||||
sc.x = @intCast(pt.active.x);
|
||||
sc.y = @intCast(pt.active.y);
|
||||
|
||||
// If we had pending wrap set and we're no longer at the end of
|
||||
// the line, we unset the pending wrap and move the cursor to
|
||||
// reflect the correct next position.
|
||||
if (sc.pending_wrap and sc.x != opts.cols - 1) {
|
||||
sc.pending_wrap = false;
|
||||
sc.x += 1;
|
||||
}
|
||||
} else {
|
||||
// I think this can happen if the screen is resized to be
|
||||
// less rows or less cols and our saved cursor moves outside
|
||||
// the active area. In this case, there isn't anything really
|
||||
// reasonable we can do so we just move the cursor to the
|
||||
// top-left. It may be reasonable to also move the cursor to
|
||||
// match the primary cursor. Any behavior is fine since this is
|
||||
// totally unspecified.
|
||||
sc.x = 0;
|
||||
sc.y = 0;
|
||||
sc.pending_wrap = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix up our hyperlink if we had one.
|
||||
if (cursor_hyperlink) |link| {
|
||||
self.startHyperlink(link.uri, switch (link.id) {
|
||||
.explicit => |v| v,
|
||||
.implicit => null,
|
||||
}) catch |err| {
|
||||
// This shouldn't happen because startHyperlink should handle
|
||||
// resizing. This only happens if we're truly out of RAM. Degrade
|
||||
// to forgetting the hyperlink.
|
||||
log.err("failed to update hyperlink on resize err={}", .{err});
|
||||
};
|
||||
|
||||
// Remove our old link
|
||||
link.deinit(self.alloc);
|
||||
self.alloc.destroy(link);
|
||||
}
|
||||
|
||||
// A tracked pin follows its content when PageList moves or replaces a
|
||||
// page. If the cursor's pin still points to the node we started with,
|
||||
// that node survived the resize and still owns the temporary style and
|
||||
// hyperlink references we added above.
|
||||
//
|
||||
// Comparing pointers is not enough by itself. PageList keeps a pool of
|
||||
// nodes, so it can destroy a node and create a replacement at the same
|
||||
// memory address. Every new use of a node gets a different serial number,
|
||||
// making the pointer-and-serial pair an O(1) identity check.
|
||||
const cursor_node_survived =
|
||||
self.cursor.page_pin.node == cursor_node and
|
||||
self.cursor.page_pin.node.serial == cursor_node_serial;
|
||||
if (comptime build_options.slow_runtime_safety) {
|
||||
assert(cursor_node_survived ==
|
||||
self.pages.nodeIsValid(cursor_node, cursor_node_serial));
|
||||
}
|
||||
|
||||
// A surviving node still contains our temporary references, so remove
|
||||
// them now. If the node was replaced, its destruction removed those
|
||||
// references along with the rest of the old page so we don't need to
|
||||
// fix it up.
|
||||
if (cursor_node_survived) {
|
||||
const page = cursor_node.page();
|
||||
if (cursor_style_id != style.default_id) {
|
||||
page.styles.release(page.memory, cursor_style_id);
|
||||
}
|
||||
if (cursor_hyperlink_id != 0) {
|
||||
page.hyperlink_set.release(page.memory, cursor_hyperlink_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clearPromptForRedraw(
|
||||
self: *Screen,
|
||||
redraw: osc.semantic_prompt.Redraw,
|
||||
) void {
|
||||
// If our cursor is on a prompt or input line, clear it so the shell can
|
||||
// redraw it. This works with OSC 133 semantic prompts.
|
||||
// redraw it. This works with OSC 133 semantic prompts. We do this after
|
||||
// the fallible resize so an error leaves the original prompt untouched.
|
||||
//
|
||||
// We check cursor.semantic_content rather than page_row.semantic_prompt
|
||||
// because some shells (e.g., Nu) mark input areas with OSC 133 B but don't
|
||||
@@ -1926,10 +2074,10 @@ pub inline fn resize(
|
||||
// would miss them. By checking semantic_content, we assume that if the
|
||||
// cursor is on anything other than command output, we're at a prompt/input
|
||||
// line and should clear from there.
|
||||
if (opts.prompt_redraw != .false and
|
||||
if (redraw != .false and
|
||||
self.cursor.semantic_content != .output)
|
||||
prompt: {
|
||||
switch (opts.prompt_redraw) {
|
||||
switch (redraw) {
|
||||
.false => unreachable,
|
||||
|
||||
// For `.last`, only clear the current line where the cursor is.
|
||||
@@ -1969,76 +2117,6 @@ pub inline fn resize(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the resize operation.
|
||||
try self.pages.resize(.{
|
||||
.rows = opts.rows,
|
||||
.cols = opts.cols,
|
||||
.reflow = opts.reflow,
|
||||
.cursor = .{
|
||||
.x = self.cursor.x,
|
||||
.y = self.cursor.y,
|
||||
.pin = self.cursor.page_pin,
|
||||
},
|
||||
});
|
||||
|
||||
// If we have no scrollback and we shrunk our rows, we must explicitly
|
||||
// erase our history. This is because PageList always keeps at least
|
||||
// a page size of history.
|
||||
if (self.no_scrollback) {
|
||||
self.pages.eraseHistory(null);
|
||||
}
|
||||
|
||||
// If our cursor was updated, we do a full reload so all our cursor
|
||||
// state is correct.
|
||||
self.cursorReload();
|
||||
|
||||
// If we reflowed a saved cursor, update it.
|
||||
if (saved_cursor_pin) |p| {
|
||||
// This should never fail because a non-null saved_cursor_pin
|
||||
// implies a non-null saved_cursor.
|
||||
const sc = &self.saved_cursor.?;
|
||||
if (self.pages.pointFromPin(.active, p.*)) |pt| {
|
||||
sc.x = @intCast(pt.active.x);
|
||||
sc.y = @intCast(pt.active.y);
|
||||
|
||||
// If we had pending wrap set and we're no longer at the end of
|
||||
// the line, we unset the pending wrap and move the cursor to
|
||||
// reflect the correct next position.
|
||||
if (sc.pending_wrap and sc.x != opts.cols - 1) {
|
||||
sc.pending_wrap = false;
|
||||
sc.x += 1;
|
||||
}
|
||||
} else {
|
||||
// I think this can happen if the screen is resized to be
|
||||
// less rows or less cols and our saved cursor moves outside
|
||||
// the active area. In this case, there isn't anything really
|
||||
// reasonable we can do so we just move the cursor to the
|
||||
// top-left. It may be reasonable to also move the cursor to
|
||||
// match the primary cursor. Any behavior is fine since this is
|
||||
// totally unspecified.
|
||||
sc.x = 0;
|
||||
sc.y = 0;
|
||||
sc.pending_wrap = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix up our hyperlink if we had one.
|
||||
if (hyperlink_) |link| {
|
||||
self.startHyperlink(link.uri, switch (link.id) {
|
||||
.explicit => |v| v,
|
||||
.implicit => null,
|
||||
}) catch |err| {
|
||||
// This shouldn't happen because startHyperlink should handle
|
||||
// resizing. This only happens if we're truly out of RAM. Degrade
|
||||
// to forgetting the hyperlink.
|
||||
log.err("failed to update hyperlink on resize err={}", .{err});
|
||||
};
|
||||
|
||||
// Remove our old link
|
||||
link.deinit(self.alloc);
|
||||
self.alloc.destroy(link);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a style attribute for the current cursor.
|
||||
@@ -7236,6 +7314,191 @@ test "Screen: resize more cols with reflow" {
|
||||
try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
|
||||
}
|
||||
|
||||
test "Screen: resize errors preserve state" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
for (std.meta.tags(resize_tw.FailPoint)) |tag| {
|
||||
const tw = resize_tw;
|
||||
defer tw.end(.reset) catch unreachable;
|
||||
|
||||
var s = try init(alloc, .{
|
||||
.cols = 10,
|
||||
.rows = 3,
|
||||
.max_scrollback = 0,
|
||||
});
|
||||
defer s.deinit();
|
||||
|
||||
s.cursorSetSemanticContent(.{ .prompt = .initial });
|
||||
try s.testWriteString("> ");
|
||||
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
|
||||
try s.testWriteString("echo");
|
||||
try s.setAttribute(.{ .bold = {} });
|
||||
try s.startHyperlink("https://example.com", "resize");
|
||||
s.saved_cursor = .{
|
||||
.x = 1,
|
||||
.y = 0,
|
||||
.style = s.cursor.style,
|
||||
.protected = s.cursor.protected,
|
||||
.pending_wrap = s.cursor.pending_wrap,
|
||||
.origin = false,
|
||||
.charset = s.charset,
|
||||
};
|
||||
|
||||
if (comptime build_options.kitty_graphics) {
|
||||
s.kitty_images.dirty = false;
|
||||
}
|
||||
|
||||
// Keep a shallow copy for all non-page state and a byte-for-byte
|
||||
// copy of the sole page so reference counts and prompt contents are
|
||||
// covered as well.
|
||||
try testing.expectEqual(s.pages.pages.first, s.pages.pages.last);
|
||||
const before = s;
|
||||
const before_viewport_pin = s.pages.viewport_pin.*;
|
||||
const before_tracked_pins = s.pages.countTrackedPins();
|
||||
const before_page = try alloc.dupe(
|
||||
u8,
|
||||
s.pages.pages.first.?.page().memory,
|
||||
);
|
||||
defer alloc.free(before_page);
|
||||
|
||||
tw.errorAlways(tag, error.OutOfMemory);
|
||||
try testing.expectError(error.OutOfMemory, s.resize(.{
|
||||
.cols = 20,
|
||||
.rows = 4,
|
||||
.prompt_redraw = .true,
|
||||
}));
|
||||
|
||||
try testing.expect(std.meta.eql(before.cursor, s.cursor));
|
||||
try testing.expect(std.meta.eql(before.saved_cursor, s.saved_cursor));
|
||||
try testing.expect(std.meta.eql(before.selection, s.selection));
|
||||
try testing.expect(std.meta.eql(before.charset, s.charset));
|
||||
try testing.expectEqual(before.protected_mode, s.protected_mode);
|
||||
try testing.expect(std.meta.eql(before.kitty_keyboard, s.kitty_keyboard));
|
||||
try testing.expect(std.meta.eql(before.semantic_prompt, s.semantic_prompt));
|
||||
try testing.expectEqual(before.dirty, s.dirty);
|
||||
try testing.expectEqual(before.pages.pages.first, s.pages.pages.first);
|
||||
try testing.expectEqual(before.pages.pages.last, s.pages.pages.last);
|
||||
try testing.expectEqual(before.pages.cols, s.pages.cols);
|
||||
try testing.expectEqual(before.pages.rows, s.pages.rows);
|
||||
try testing.expectEqual(before.pages.total_rows, s.pages.total_rows);
|
||||
try testing.expectEqual(before.pages.viewport, s.pages.viewport);
|
||||
try testing.expectEqual(before_viewport_pin, s.pages.viewport_pin.*);
|
||||
try testing.expectEqual(before_tracked_pins, s.pages.countTrackedPins());
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
before_page,
|
||||
s.pages.pages.first.?.page().memory,
|
||||
);
|
||||
if (comptime build_options.kitty_graphics) {
|
||||
try testing.expectEqual(
|
||||
before.kitty_images.dirty,
|
||||
s.kitty_images.dirty,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "Screen: resize cursor references when node survives" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{
|
||||
.cols = 5,
|
||||
.rows = 3,
|
||||
.max_scrollback = 1000,
|
||||
});
|
||||
defer s.deinit();
|
||||
|
||||
try s.setAttribute(.bold);
|
||||
try s.startHyperlink("https://example.com/", "resize");
|
||||
try s.testWriteString("abc");
|
||||
|
||||
const original_node = s.cursor.page_pin.node;
|
||||
const original_serial = original_node.serial;
|
||||
{
|
||||
const page = original_node.page();
|
||||
try testing.expectEqual(
|
||||
4,
|
||||
page.styles.refCount(page.memory, s.cursor.style_id),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
4,
|
||||
page.hyperlink_set.refCount(page.memory, s.cursor.hyperlink_id),
|
||||
);
|
||||
}
|
||||
|
||||
// A row-only resize grows the existing page without replacing the
|
||||
// cursor's node. The temporary resize references must be released from
|
||||
// this page after the cursor state is restored.
|
||||
try s.resize(.{ .cols = 5, .rows = 4 });
|
||||
|
||||
try testing.expectEqual(original_node, s.cursor.page_pin.node);
|
||||
try testing.expectEqual(original_serial, s.cursor.page_pin.node.serial);
|
||||
{
|
||||
const page = s.cursor.page_pin.node.page();
|
||||
try testing.expectEqual(
|
||||
4,
|
||||
page.styles.refCount(page.memory, s.cursor.style_id),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
4,
|
||||
page.hyperlink_set.refCount(page.memory, s.cursor.hyperlink_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "Screen: resize cursor references when node is replaced" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{
|
||||
.cols = 5,
|
||||
.rows = 3,
|
||||
.max_scrollback = 1000,
|
||||
});
|
||||
defer s.deinit();
|
||||
|
||||
try s.setAttribute(.bold);
|
||||
try s.startHyperlink("https://example.com/", "resize");
|
||||
try s.testWriteString("abc");
|
||||
|
||||
const original_node = s.cursor.page_pin.node;
|
||||
const original_serial = original_node.serial;
|
||||
{
|
||||
const page = original_node.page();
|
||||
try testing.expectEqual(
|
||||
4,
|
||||
page.styles.refCount(page.memory, s.cursor.style_id),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
4,
|
||||
page.hyperlink_set.refCount(page.memory, s.cursor.hyperlink_id),
|
||||
);
|
||||
}
|
||||
|
||||
// A column resize with reflow replaces the page and remaps the tracked
|
||||
// cursor pin. The old page owns the temporary references, so destroying
|
||||
// it must account for them without attempting to release them afterward.
|
||||
try s.resize(.{ .cols = 10, .rows = 3 });
|
||||
|
||||
try testing.expect(
|
||||
s.cursor.page_pin.node != original_node or
|
||||
s.cursor.page_pin.node.serial != original_serial,
|
||||
);
|
||||
{
|
||||
const page = s.cursor.page_pin.node.page();
|
||||
try testing.expectEqual(
|
||||
4,
|
||||
page.styles.refCount(page.memory, s.cursor.style_id),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
4,
|
||||
page.hyperlink_set.refCount(page.memory, s.cursor.hyperlink_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "Screen: resize more rows and cols with wrapping" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@@ -7,6 +7,7 @@ const std = @import("std");
|
||||
const build_options = @import("terminal_options");
|
||||
const lib = @import("lib.zig");
|
||||
const assert = @import("../quirks.zig").inlineAssert;
|
||||
const tripwire = @import("../tripwire.zig");
|
||||
const testing = std.testing;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const unicode = @import("../unicode/main.zig");
|
||||
@@ -3519,73 +3520,308 @@ pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void {
|
||||
self.modes.set(.@"132_column", mode == .@"132_cols");
|
||||
|
||||
// Resize to the requested size
|
||||
try self.resize(
|
||||
alloc,
|
||||
switch (mode) {
|
||||
try self.resize(alloc, .{
|
||||
.cols = switch (mode) {
|
||||
.@"132_cols" => 132,
|
||||
.@"80_cols" => 80,
|
||||
},
|
||||
self.rows,
|
||||
);
|
||||
.rows = self.rows,
|
||||
});
|
||||
|
||||
// Erase our display and move our cursor.
|
||||
self.eraseDisplay(.complete, false);
|
||||
self.setCursorPos(1, 1);
|
||||
}
|
||||
|
||||
/// A terminal resize expressed in cells with optional per-cell pixel
|
||||
/// geometry. It is highly recommended that callers supply cell geometry
|
||||
/// but a terminal can technically function without it (but some reports
|
||||
/// like certain mouse reporting modes and Kitty image protocol will
|
||||
/// not be functional).
|
||||
///
|
||||
/// Cell pixel dimensions must already be scaled for the current display DPI.
|
||||
pub const Resize = struct {
|
||||
cols: size.CellCountInt,
|
||||
rows: size.CellCountInt,
|
||||
cell_size_px: ?struct {
|
||||
width: u32,
|
||||
height: u32,
|
||||
} = null,
|
||||
};
|
||||
|
||||
pub const ResizeError = error{
|
||||
/// Resize requires allocation
|
||||
OutOfMemory,
|
||||
|
||||
/// Input value was invalid, such as a 0-sized dimension.
|
||||
InvalidValue,
|
||||
};
|
||||
|
||||
const resize_tw = tripwire.module(enum {
|
||||
tabstops,
|
||||
primary_screen,
|
||||
alternate_screen,
|
||||
alternate_screen_init,
|
||||
}, resize);
|
||||
|
||||
/// Resize the underlying terminal.
|
||||
///
|
||||
/// This has follow-on impacts:
|
||||
///
|
||||
/// - If the column count changes, tabstops are reset.
|
||||
/// - The scroll region is always reset
|
||||
/// - Synchronized output mode is reset for every successful resize.
|
||||
///
|
||||
/// This handles errors gracefully and recovers the terminal back to
|
||||
/// a clean usable state.
|
||||
///
|
||||
/// The only error handling edge case is in the highly exceptional scenario
|
||||
/// where the primary screen can be resized but the alternate screen cannot.
|
||||
/// In this scenario, we attempt to clear the alt screen at the desired
|
||||
/// new size. If that fails, we unconditionally deallocate the alt screen
|
||||
/// and move to primary screen. This can break terminal programs but it
|
||||
/// requires a really particular scenario where memory exists for one
|
||||
/// but not the other and we do our best.
|
||||
pub fn resize(
|
||||
self: *Terminal,
|
||||
alloc: Allocator,
|
||||
cols: size.CellCountInt,
|
||||
rows: size.CellCountInt,
|
||||
) !void {
|
||||
// If our cols/rows didn't change then we're done
|
||||
if (self.cols == cols and self.rows == rows) return;
|
||||
opts: Resize,
|
||||
) ResizeError!void {
|
||||
const tw = resize_tw;
|
||||
|
||||
// Resize our tabstops
|
||||
if (self.cols != cols) {
|
||||
const tabstops: Tabstops = try .init(
|
||||
alloc,
|
||||
cols,
|
||||
TABSTOP_INTERVAL,
|
||||
);
|
||||
self.tabstops.deinit(alloc);
|
||||
self.tabstops = tabstops;
|
||||
// Screen and scrolling-region invariants require non-zero dimensions.
|
||||
// Validate before changing any terminal state.
|
||||
if (opts.cols == 0 or opts.rows == 0) return error.InvalidValue;
|
||||
|
||||
// Pixel geometry and synchronized output are updated on every valid
|
||||
// resize attempt, including one that doesn't change the grid dimensions.
|
||||
// Save their old values so later allocation failures can roll them back.
|
||||
const old_width_px = self.width_px;
|
||||
const old_height_px = self.height_px;
|
||||
const old_synchronized_output = self.modes.get(.synchronized_output);
|
||||
errdefer {
|
||||
self.width_px = old_width_px;
|
||||
self.height_px = old_height_px;
|
||||
self.modes.set(.synchronized_output, old_synchronized_output);
|
||||
}
|
||||
|
||||
// Resize primary screen, which supports reflow
|
||||
// If our pixel geometry was set, then we set it even if our rows/cols
|
||||
// didn't change.
|
||||
if (opts.cell_size_px) |cell_size| {
|
||||
self.width_px = std.math.mul(
|
||||
u32,
|
||||
opts.cols,
|
||||
cell_size.width,
|
||||
) catch std.math.maxInt(u32);
|
||||
self.height_px = std.math.mul(
|
||||
u32,
|
||||
opts.rows,
|
||||
cell_size.height,
|
||||
) catch std.math.maxInt(u32);
|
||||
}
|
||||
|
||||
self.modes.set(.synchronized_output, false);
|
||||
|
||||
// If our cols/rows didn't change, skip grid work but still apply pixels.
|
||||
if (self.cols == opts.cols and self.rows == opts.rows) return;
|
||||
|
||||
// Build replacement tabstops without touching the current table. Keep
|
||||
// ownership here until every fallible resize operation has succeeded.
|
||||
var new_tabstops: ?Tabstops = null;
|
||||
errdefer if (new_tabstops) |*v| v.deinit(alloc);
|
||||
if (self.cols != opts.cols) {
|
||||
try tw.check(.tabstops);
|
||||
new_tabstops = try .init(
|
||||
alloc,
|
||||
opts.cols,
|
||||
TABSTOP_INTERVAL,
|
||||
);
|
||||
}
|
||||
|
||||
// Resize primary screen, which supports reflow. We do this first
|
||||
// because the cleanup situation is a lot better if this succeeds
|
||||
// and alt fails than the reverse.
|
||||
try tw.check(.primary_screen);
|
||||
const primary = self.screens.get(.primary).?;
|
||||
try primary.resize(.{
|
||||
.cols = cols,
|
||||
.rows = rows,
|
||||
.cols = opts.cols,
|
||||
.rows = opts.rows,
|
||||
.reflow = self.modes.get(.wraparound),
|
||||
.prompt_redraw = self.flags.shell_redraws_prompt,
|
||||
});
|
||||
|
||||
// Alternate screen, if it exists, doesn't reflow
|
||||
if (self.screens.get(.alternate)) |alt| try alt.resize(.{
|
||||
.cols = cols,
|
||||
.rows = rows,
|
||||
.reflow = false,
|
||||
});
|
||||
// Alternate screen, if it exists, doesn't reflow. The primary resize
|
||||
// above can't be losslessly undone, so if the alternate resize fails we
|
||||
// replace it with an empty screen at the requested size. If that
|
||||
// also fails, we fall back to the primary screen.
|
||||
if (self.screens.get(.alternate)) |alt| alt: {
|
||||
const err: ResizeError = resize: {
|
||||
tw.check(.alternate_screen) catch |err| break :resize err;
|
||||
alt.resize(.{
|
||||
.cols = opts.cols,
|
||||
.rows = opts.rows,
|
||||
.reflow = false,
|
||||
}) catch |err| break :resize err;
|
||||
|
||||
// Resize succeeded.
|
||||
break :alt;
|
||||
};
|
||||
|
||||
log.warn("alternate screen resize failed, replacing it err={}", .{err});
|
||||
|
||||
// If the alternate screen isn't active, then we just free it
|
||||
// and move on. It'll be reallocated when it gets reinitialized lazily.
|
||||
// In this case, we just lose the prior data if the terminal program
|
||||
// expected it to be saved.
|
||||
if (self.screens.active_key != .alternate) {
|
||||
self.screens.remove(alloc, .alternate);
|
||||
break :alt;
|
||||
}
|
||||
|
||||
// The alt screen is active, so we temporarily switch to primary
|
||||
// so we can safely remove the alt and recreate it blank. This loses
|
||||
// the data but hopefully keeps us on the alt screen.
|
||||
const charset = alt.charset;
|
||||
self.screens.switchTo(.primary);
|
||||
self.screens.remove(alloc, .alternate);
|
||||
|
||||
// Replace the alt screen with an empty version. If this fails
|
||||
// we just go back to the primary screen. Not great, but best
|
||||
// we can do.
|
||||
tw.check(.alternate_screen_init) catch break :alt;
|
||||
const replacement = self.screens.getInit(alloc, .alternate, .{
|
||||
.cols = opts.cols,
|
||||
.rows = opts.rows,
|
||||
.max_scrollback = 0,
|
||||
.kitty_image_storage_limit = if (comptime build_options.kitty_graphics)
|
||||
primary.kitty_images.total_limit
|
||||
else
|
||||
0,
|
||||
.kitty_image_loading_limits = if (comptime build_options.kitty_graphics)
|
||||
primary.kitty_images.image_limits
|
||||
else {},
|
||||
}) catch |init_err| {
|
||||
log.warn(
|
||||
"alternate screen replacement failed, falling back to primary err={}",
|
||||
.{init_err},
|
||||
);
|
||||
break :alt;
|
||||
};
|
||||
|
||||
replacement.charset = charset;
|
||||
self.screens.switchTo(.alternate);
|
||||
}
|
||||
|
||||
// No more failures are allowed after this point because the screens have
|
||||
// committed their new sizes and the remaining Terminal state must follow.
|
||||
errdefer comptime unreachable;
|
||||
|
||||
// All fallible work is complete. Replace the old tabstop table only now.
|
||||
if (new_tabstops) |v| {
|
||||
self.tabstops.deinit(alloc);
|
||||
self.tabstops = v;
|
||||
new_tabstops = null;
|
||||
}
|
||||
|
||||
// Whenever we resize we just mark it as a screen clear
|
||||
self.flags.dirty.clear = true;
|
||||
|
||||
// Set our size
|
||||
self.cols = cols;
|
||||
self.rows = rows;
|
||||
self.cols = opts.cols;
|
||||
self.rows = opts.rows;
|
||||
|
||||
// Reset the scrolling region
|
||||
self.scrolling_region = .{
|
||||
.top = 0,
|
||||
.bottom = rows - 1,
|
||||
.bottom = opts.rows - 1,
|
||||
.left = 0,
|
||||
.right = cols - 1,
|
||||
.right = opts.cols - 1,
|
||||
};
|
||||
}
|
||||
|
||||
test "Terminal: resize resets synchronized output" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
t.modes.set(.synchronized_output, true);
|
||||
try t.resize(alloc, .{ .cols = 10, .rows = 5 });
|
||||
try testing.expect(!t.modes.get(.synchronized_output));
|
||||
}
|
||||
|
||||
test "Terminal: resize rejects zero dimensions before mutation" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
t.width_px = 100;
|
||||
t.height_px = 100;
|
||||
t.flags.dirty.clear = false;
|
||||
|
||||
try testing.expectError(error.InvalidValue, t.resize(alloc, .{
|
||||
.cols = 0,
|
||||
.rows = 5,
|
||||
.cell_size_px = .{ .width = 9, .height = 18 },
|
||||
}));
|
||||
try testing.expectError(error.InvalidValue, t.resize(alloc, .{
|
||||
.cols = 10,
|
||||
.rows = 0,
|
||||
.cell_size_px = .{ .width = 9, .height = 18 },
|
||||
}));
|
||||
|
||||
try testing.expectEqual(@as(size.CellCountInt, 10), t.cols);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 5), t.rows);
|
||||
try testing.expectEqual(@as(u32, 100), t.width_px);
|
||||
try testing.expectEqual(@as(u32, 100), t.height_px);
|
||||
try testing.expect(!t.flags.dirty.clear);
|
||||
}
|
||||
|
||||
test "Terminal: resize preserves pixel dimensions when omitted" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
t.width_px = 90;
|
||||
t.height_px = 90;
|
||||
try t.resize(alloc, .{ .cols = 20, .rows = 10 });
|
||||
|
||||
try testing.expectEqual(@as(u32, 90), t.width_px);
|
||||
try testing.expectEqual(@as(u32, 90), t.height_px);
|
||||
}
|
||||
|
||||
test "Terminal: resize updates pixels without changing cell dimensions" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
try t.resize(alloc, .{
|
||||
.cols = 10,
|
||||
.rows = 5,
|
||||
.cell_size_px = .{ .width = 9, .height = 18 },
|
||||
});
|
||||
|
||||
try testing.expectEqual(@as(u32, 90), t.width_px);
|
||||
try testing.expectEqual(@as(u32, 90), t.height_px);
|
||||
}
|
||||
|
||||
test "Terminal: resize pixel dimensions saturate" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 2, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
try t.resize(alloc, .{
|
||||
.cols = 2,
|
||||
.rows = 3,
|
||||
.cell_size_px = .{
|
||||
.width = std.math.maxInt(u32),
|
||||
.height = std.math.maxInt(u32),
|
||||
},
|
||||
});
|
||||
|
||||
try testing.expectEqual(std.math.maxInt(u32), t.width_px);
|
||||
try testing.expectEqual(std.math.maxInt(u32), t.height_px);
|
||||
}
|
||||
|
||||
test "Terminal: resize preserves tabstops on allocation failure" {
|
||||
var failing = testing.FailingAllocator.init(testing.allocator, .{});
|
||||
const alloc = failing.allocator();
|
||||
@@ -3593,12 +3829,199 @@ test "Terminal: resize preserves tabstops on allocation failure" {
|
||||
defer t.deinit(alloc);
|
||||
|
||||
failing.fail_index = failing.alloc_index;
|
||||
try testing.expectError(error.OutOfMemory, t.resize(alloc, 513, 1));
|
||||
try testing.expectError(error.OutOfMemory, t.resize(alloc, .{
|
||||
.cols = 513,
|
||||
.rows = 1,
|
||||
}));
|
||||
|
||||
try testing.expectEqual(@as(size.CellCountInt, 10), t.cols);
|
||||
try testing.expect(t.tabstops.get(8));
|
||||
}
|
||||
|
||||
test "Terminal: resize failure paths preserve consistent state" {
|
||||
const alloc = testing.allocator;
|
||||
|
||||
for ([_]resize_tw.FailPoint{
|
||||
.tabstops,
|
||||
.primary_screen,
|
||||
.alternate_screen,
|
||||
}) |tag| {
|
||||
const tw = resize_tw;
|
||||
defer tw.end(.reset) catch unreachable;
|
||||
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
try t.printString("primary");
|
||||
_ = try t.switchScreen(.alternate);
|
||||
try t.printString("alternate");
|
||||
_ = try t.switchScreen(.primary);
|
||||
|
||||
t.width_px = 100;
|
||||
t.height_px = 50;
|
||||
t.modes.set(.synchronized_output, true);
|
||||
t.flags.dirty.clear = false;
|
||||
t.scrolling_region = .{
|
||||
.top = 1,
|
||||
.bottom = 2,
|
||||
.left = 1,
|
||||
.right = 8,
|
||||
};
|
||||
t.tabstops.unset(8);
|
||||
t.tabstops.set(3);
|
||||
|
||||
const primary = t.screens.get(.primary).?;
|
||||
const alternate = t.screens.get(.alternate).?;
|
||||
const alternate_generation = t.screens.generation(.alternate);
|
||||
try testing.expectEqual(primary.pages.pages.first, primary.pages.pages.last);
|
||||
try testing.expectEqual(alternate.pages.pages.first, alternate.pages.pages.last);
|
||||
|
||||
const before = t;
|
||||
const before_primary = primary.*;
|
||||
const before_alternate = alternate.*;
|
||||
const before_primary_page = try alloc.dupe(
|
||||
u8,
|
||||
primary.pages.pages.first.?.page().memory,
|
||||
);
|
||||
defer alloc.free(before_primary_page);
|
||||
const before_alternate_page = try alloc.dupe(
|
||||
u8,
|
||||
alternate.pages.pages.first.?.page().memory,
|
||||
);
|
||||
defer alloc.free(before_alternate_page);
|
||||
tw.errorAlways(tag, error.OutOfMemory);
|
||||
|
||||
// A failure after the primary screen has resized is recovered by
|
||||
// dropping the inactive alternate and completing the resize. This
|
||||
// also proves the alternate tripwire is after the primary resize
|
||||
// rather than acting as a preflight check.
|
||||
if (tag == .alternate_screen) {
|
||||
try t.resize(alloc, .{
|
||||
.cols = 513,
|
||||
.rows = 4,
|
||||
.cell_size_px = .{ .width = 9, .height = 18 },
|
||||
});
|
||||
|
||||
try testing.expectEqual(@as(size.CellCountInt, 513), t.cols);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 4), t.rows);
|
||||
try testing.expectEqual(@as(u32, 4617), t.width_px);
|
||||
try testing.expectEqual(@as(u32, 72), t.height_px);
|
||||
try testing.expect(!t.modes.get(.synchronized_output));
|
||||
try testing.expect(t.flags.dirty.clear);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 513), primary.pages.cols);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 4), primary.pages.rows);
|
||||
try testing.expectEqual(@as(?*Screen, null), t.screens.get(.alternate));
|
||||
try testing.expectEqual(
|
||||
alternate_generation +% 1,
|
||||
t.screens.generation(.alternate),
|
||||
);
|
||||
try testing.expectEqual(.primary, t.screens.active_key);
|
||||
try testing.expectEqual(primary, t.screens.active);
|
||||
try testing.expect(t.tabstops.get(8));
|
||||
try testing.expect(!t.tabstops.get(3));
|
||||
|
||||
// The alternate is recreated lazily at the terminal's new size.
|
||||
_ = try t.switchScreen(.alternate);
|
||||
const replacement = t.screens.get(.alternate).?;
|
||||
try testing.expectEqual(@as(size.CellCountInt, 513), replacement.pages.cols);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 4), replacement.pages.rows);
|
||||
try testing.expect(replacement.pages.getCell(.{ .active = .{} }).?.cell.isEmpty());
|
||||
continue;
|
||||
}
|
||||
|
||||
try testing.expectError(error.OutOfMemory, t.resize(alloc, .{
|
||||
.cols = 513,
|
||||
.rows = 4,
|
||||
.cell_size_px = .{ .width = 9, .height = 18 },
|
||||
}));
|
||||
|
||||
try testing.expectEqual(before.width_px, t.width_px);
|
||||
try testing.expectEqual(before.height_px, t.height_px);
|
||||
try testing.expect(std.meta.eql(before.modes, t.modes));
|
||||
try testing.expectEqual(before.cols, t.cols);
|
||||
try testing.expectEqual(before.rows, t.rows);
|
||||
try testing.expectEqual(before.scrolling_region, t.scrolling_region);
|
||||
try testing.expectEqual(before.flags, t.flags);
|
||||
try testing.expect(std.meta.eql(before.tabstops, t.tabstops));
|
||||
try testing.expectEqual(before.screens.active_key, t.screens.active_key);
|
||||
try testing.expectEqual(before.screens.active, t.screens.active);
|
||||
|
||||
try testing.expectEqual(before_primary.pages.cols, primary.pages.cols);
|
||||
try testing.expectEqual(before_primary.pages.rows, primary.pages.rows);
|
||||
try testing.expectEqual(
|
||||
before_primary.pages.total_rows,
|
||||
primary.pages.total_rows,
|
||||
);
|
||||
try testing.expect(std.meta.eql(before_primary.cursor, primary.cursor));
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
before_primary_page,
|
||||
primary.pages.pages.first.?.page().memory,
|
||||
);
|
||||
|
||||
try testing.expectEqual(before_alternate.pages.cols, alternate.pages.cols);
|
||||
try testing.expectEqual(before_alternate.pages.rows, alternate.pages.rows);
|
||||
try testing.expectEqual(
|
||||
before_alternate.pages.total_rows,
|
||||
alternate.pages.total_rows,
|
||||
);
|
||||
try testing.expect(std.meta.eql(before_alternate.cursor, alternate.cursor));
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
before_alternate_page,
|
||||
alternate.pages.pages.first.?.page().memory,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: alternate resize failure replaces active alternate screen" {
|
||||
const alloc = testing.allocator;
|
||||
const tw = resize_tw;
|
||||
defer tw.end(.reset) catch unreachable;
|
||||
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
_ = try t.switchScreen(.alternate);
|
||||
try testing.expectEqual(.alternate, t.screens.active_key);
|
||||
t.screens.active.charset.gl = .G1;
|
||||
try t.printString("alternate");
|
||||
const generation = t.screens.generation(.alternate);
|
||||
|
||||
tw.errorAlways(.alternate_screen, error.OutOfMemory);
|
||||
try t.resize(alloc, .{ .cols = 20, .rows = 4 });
|
||||
|
||||
const alternate = t.screens.get(.alternate).?;
|
||||
try testing.expectEqual(.alternate, t.screens.active_key);
|
||||
try testing.expectEqual(alternate, t.screens.active);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 20), alternate.pages.cols);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 4), alternate.pages.rows);
|
||||
try testing.expect(alternate.pages.getCell(.{ .active = .{} }).?.cell.isEmpty());
|
||||
try testing.expectEqual(.G1, alternate.charset.gl);
|
||||
try testing.expectEqual(generation +% 1, t.screens.generation(.alternate));
|
||||
}
|
||||
|
||||
test "Terminal: alternate resize replacement failure falls back to primary" {
|
||||
const alloc = testing.allocator;
|
||||
const tw = resize_tw;
|
||||
defer tw.end(.reset) catch unreachable;
|
||||
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
_ = try t.switchScreen(.alternate);
|
||||
tw.errorAlways(.alternate_screen, error.OutOfMemory);
|
||||
tw.errorAlways(.alternate_screen_init, error.OutOfMemory);
|
||||
try t.resize(alloc, .{ .cols = 20, .rows = 4 });
|
||||
|
||||
const primary = t.screens.get(.primary).?;
|
||||
try testing.expectEqual(@as(?*Screen, null), t.screens.get(.alternate));
|
||||
try testing.expectEqual(.primary, t.screens.active_key);
|
||||
try testing.expectEqual(primary, t.screens.active);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 20), primary.pages.cols);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 4), primary.pages.rows);
|
||||
}
|
||||
|
||||
/// Set the pwd for the terminal.
|
||||
pub fn setPwd(self: *Terminal, pwd: []const u8) !void {
|
||||
if (pwd.len == 0) {
|
||||
@@ -11819,7 +12242,7 @@ test "Terminal: saveCursor resize" {
|
||||
|
||||
t.setCursorPos(1, 10);
|
||||
t.saveCursor();
|
||||
try t.resize(alloc, 5, 5);
|
||||
try t.resize(alloc, .{ .cols = 5, .rows = 5 });
|
||||
t.restoreCursor();
|
||||
try t.print('X');
|
||||
|
||||
@@ -13837,7 +14260,7 @@ test "Terminal: resize less cols with wide char then print" {
|
||||
|
||||
try t.print('x');
|
||||
try t.print('😀'); // 0x1F600
|
||||
try t.resize(alloc, 2, 3);
|
||||
try t.resize(alloc, .{ .cols = 2, .rows = 3 });
|
||||
t.setCursorPos(1, 2);
|
||||
try t.print('😀'); // 0x1F600
|
||||
}
|
||||
@@ -13854,11 +14277,11 @@ test "Terminal: resize with left and right margin set" {
|
||||
t.modes.set(.enable_left_and_right_margin, true);
|
||||
try t.print('0');
|
||||
t.modes.set(.enable_mode_3, true);
|
||||
try t.resize(alloc, cols, rows);
|
||||
try t.resize(alloc, .{ .cols = cols, .rows = rows });
|
||||
t.setLeftAndRightMargin(2, 0);
|
||||
try t.printRepeat(1850);
|
||||
_ = t.modes.restore(.enable_mode_3);
|
||||
try t.resize(alloc, cols, rows);
|
||||
try t.resize(alloc, .{ .cols = cols, .rows = rows });
|
||||
}
|
||||
|
||||
// https://github.com/mitchellh/ghostty/issues/1343
|
||||
@@ -13875,7 +14298,7 @@ test "Terminal: resize with wraparound off" {
|
||||
try t.print('2');
|
||||
try t.print('3');
|
||||
const new_cols = 2;
|
||||
try t.resize(alloc, new_cols, rows);
|
||||
try t.resize(alloc, .{ .cols = new_cols, .rows = rows });
|
||||
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
@@ -13895,7 +14318,7 @@ test "Terminal: resize with wraparound on" {
|
||||
try t.print('2');
|
||||
try t.print('3');
|
||||
const new_cols = 2;
|
||||
try t.resize(alloc, new_cols, rows);
|
||||
try t.resize(alloc, .{ .cols = new_cols, .rows = rows });
|
||||
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
@@ -13919,7 +14342,7 @@ test "Terminal: resize with high unique style per cell" {
|
||||
}
|
||||
}
|
||||
|
||||
try t.resize(alloc, 60, 30);
|
||||
try t.resize(alloc, .{ .cols = 60, .rows = 30 });
|
||||
}
|
||||
|
||||
test "Terminal: resize with high unique style per cell with wrapping" {
|
||||
@@ -13940,7 +14363,7 @@ test "Terminal: resize with high unique style per cell with wrapping" {
|
||||
try t.print('x');
|
||||
}
|
||||
|
||||
try t.resize(alloc, 60, 30);
|
||||
try t.resize(alloc, .{ .cols = 60, .rows = 30 });
|
||||
}
|
||||
|
||||
test "Terminal: resize with reflow and saved cursor" {
|
||||
@@ -13965,7 +14388,7 @@ test "Terminal: resize with reflow and saved cursor" {
|
||||
}
|
||||
|
||||
t.saveCursor();
|
||||
try t.resize(alloc, 5, 3);
|
||||
try t.resize(alloc, .{ .cols = 5, .rows = 3 });
|
||||
t.restoreCursor();
|
||||
|
||||
{
|
||||
@@ -14006,7 +14429,7 @@ test "Terminal: resize with reflow and saved cursor pending wrap" {
|
||||
}
|
||||
|
||||
t.saveCursor();
|
||||
try t.resize(alloc, 5, 3);
|
||||
try t.resize(alloc, .{ .cols = 5, .rows = 3 });
|
||||
t.restoreCursor();
|
||||
|
||||
{
|
||||
|
||||
@@ -651,34 +651,17 @@ pub fn resize(
|
||||
cell_height_px: u32,
|
||||
) callconv(lib.calling_conv) Result {
|
||||
const wrapper = terminal_ orelse return .invalid_value;
|
||||
const t = wrapper.terminal;
|
||||
if (cols == 0 or rows == 0) return .invalid_value;
|
||||
t.resize(t.gpa(), cols, rows) catch return .out_of_memory;
|
||||
|
||||
// Update pixel sizes
|
||||
t.width_px = std.math.mul(u32, cols, cell_width_px) catch std.math.maxInt(u32);
|
||||
t.height_px = std.math.mul(u32, rows, cell_height_px) catch std.math.maxInt(u32);
|
||||
|
||||
// Disable synchronized output mode so that we show changes
|
||||
// immediately for a resize. This is allowed by the spec.
|
||||
t.modes.set(.synchronized_output, false);
|
||||
|
||||
// If we have in-band size reporting enabled, send a report.
|
||||
if (t.modes.get(.in_band_size_reports)) in_band: {
|
||||
const func = wrapper.effects.write_pty orelse break :in_band;
|
||||
|
||||
var buf: [1024]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
size_report.encode(&writer, .mode_2048, .{
|
||||
.rows = rows,
|
||||
.columns = cols,
|
||||
.cell_width = cell_width_px,
|
||||
.cell_height = cell_height_px,
|
||||
}) catch break :in_band;
|
||||
|
||||
const data = writer.buffered();
|
||||
func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len);
|
||||
}
|
||||
wrapper.stream.handler.resize(.{
|
||||
.cols = cols,
|
||||
.rows = rows,
|
||||
.cell_size_px = .{
|
||||
.width = cell_width_px,
|
||||
.height = cell_height_px,
|
||||
},
|
||||
}) catch |err| return switch (err) {
|
||||
error.InvalidValue => .invalid_value,
|
||||
error.OutOfMemory => .out_of_memory,
|
||||
};
|
||||
|
||||
return .success;
|
||||
}
|
||||
@@ -3368,11 +3351,12 @@ test "resize updates pixel dimensions" {
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18));
|
||||
// Pixel geometry must still be applied when the cell dimensions match.
|
||||
try testing.expectEqual(Result.success, resize(t, 80, 24, 9, 18));
|
||||
|
||||
const zt = t.?.terminal;
|
||||
try testing.expectEqual(@as(u32, 100 * 9), zt.width_px);
|
||||
try testing.expectEqual(@as(u32, 40 * 18), zt.height_px);
|
||||
try testing.expectEqual(@as(u32, 80 * 9), zt.width_px);
|
||||
try testing.expectEqual(@as(u32, 24 * 18), zt.height_px);
|
||||
}
|
||||
|
||||
test "resize pixel overflow saturates" {
|
||||
@@ -3411,7 +3395,8 @@ test "resize disables synchronized output" {
|
||||
const zt = t.?.terminal;
|
||||
zt.modes.set(.synchronized_output, true);
|
||||
|
||||
try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18));
|
||||
// The terminal-level reset must run even if grid work is unnecessary.
|
||||
try testing.expectEqual(Result.success, resize(t, 80, 24, 9, 18));
|
||||
try testing.expect(!zt.modes.get(.synchronized_output));
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,38 @@ pub const Handler = struct {
|
||||
self.apc_handler.deinit();
|
||||
}
|
||||
|
||||
/// Resize the terminal and apply any side effects (if supported)
|
||||
/// as a result of that.
|
||||
///
|
||||
/// This is different than a direct `Terminal.resize` operation
|
||||
/// because it also handles the side effects like mode 2048 in-band
|
||||
/// size reports if write_pty is set.
|
||||
pub fn resize(self: *Handler, value: Terminal.Resize) !void {
|
||||
try self.terminal.resize(self.terminal.gpa(), value);
|
||||
|
||||
// Mode 2048 reports require complete, current cell pixel geometry.
|
||||
const cell_size = value.cell_size_px orelse return;
|
||||
|
||||
// If we have no in-band size reports enabled then do nothing.
|
||||
if (!self.terminal.modes.get(.in_band_size_reports)) return;
|
||||
|
||||
// If we have no write_pty effect, do nothing.
|
||||
const write_pty = self.effects.write_pty orelse return;
|
||||
|
||||
// The maximum mode-2048 response is covered by size_report's maximum
|
||||
// value test. Reserve the final byte for the callback's sentinel.
|
||||
var buf: [128]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(buf[0 .. buf.len - 1]);
|
||||
size_report.encode(&writer, .mode_2048, .{
|
||||
.rows = value.rows,
|
||||
.columns = value.cols,
|
||||
.cell_width = cell_size.width,
|
||||
.cell_height = cell_size.height,
|
||||
}) catch unreachable;
|
||||
buf[writer.end] = 0;
|
||||
write_pty(self, buf[0..writer.end :0]);
|
||||
}
|
||||
|
||||
pub fn vt(
|
||||
self: *Handler,
|
||||
comptime action: Action.Tag,
|
||||
@@ -881,6 +913,193 @@ pub const Handler = struct {
|
||||
}
|
||||
};
|
||||
|
||||
test "resize clears synchronized output on unchanged cell dimensions" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
t.modes.set(.synchronized_output, true);
|
||||
try s.handler.resize(.{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.cell_size_px = .{ .width = 9, .height = 18 },
|
||||
});
|
||||
|
||||
try testing.expect(!t.modes.get(.synchronized_output));
|
||||
try testing.expectEqual(@as(u32, 720), t.width_px);
|
||||
try testing.expectEqual(@as(u32, 432), t.height_px);
|
||||
}
|
||||
|
||||
test "resize reports mode 2048 geometry" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = struct {
|
||||
var response: [128]u8 = undefined;
|
||||
var response_len: usize = 0;
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
@memcpy(response[0..data.len], data);
|
||||
response_len = data.len;
|
||||
}
|
||||
};
|
||||
S.response_len = 0;
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
var s: Stream = .initAlloc(testing.allocator, handler);
|
||||
defer s.deinit();
|
||||
|
||||
t.modes.set(.in_band_size_reports, true);
|
||||
try s.handler.resize(.{
|
||||
.cols = 100,
|
||||
.rows = 40,
|
||||
.cell_size_px = .{ .width = 9, .height = 18 },
|
||||
});
|
||||
|
||||
try testing.expectEqualStrings(
|
||||
"\x1B[48;40;100;720;900t",
|
||||
S.response[0..S.response_len],
|
||||
);
|
||||
}
|
||||
|
||||
test "resize suppresses mode 2048 reports" {
|
||||
const S = struct {
|
||||
var calls: usize = 0;
|
||||
|
||||
fn writePty(_: *Handler, _: [:0]const u8) void {
|
||||
calls += 1;
|
||||
}
|
||||
};
|
||||
S.calls = 0;
|
||||
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
var s: Stream = .initAlloc(testing.allocator, handler);
|
||||
defer s.deinit();
|
||||
|
||||
// Disabled mode suppresses a report even with pixels and a callback.
|
||||
try s.handler.resize(.{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.cell_size_px = .{ .width = 9, .height = 18 },
|
||||
});
|
||||
try testing.expectEqual(@as(usize, 0), S.calls);
|
||||
|
||||
// Missing pixel geometry suppresses a report even with the mode enabled.
|
||||
t.modes.set(.in_band_size_reports, true);
|
||||
try s.handler.resize(.{ .cols = 80, .rows = 24 });
|
||||
try testing.expectEqual(@as(usize, 0), S.calls);
|
||||
|
||||
// A read-only stream has no write effect and remains successful.
|
||||
var readonly_terminal: Terminal = try .init(
|
||||
testing.allocator,
|
||||
.{ .cols = 80, .rows = 24 },
|
||||
);
|
||||
defer readonly_terminal.deinit(testing.allocator);
|
||||
readonly_terminal.modes.set(.in_band_size_reports, true);
|
||||
var readonly_stream: Stream = .initAlloc(
|
||||
testing.allocator,
|
||||
.init(&readonly_terminal),
|
||||
);
|
||||
defer readonly_stream.deinit();
|
||||
try readonly_stream.handler.resize(.{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.cell_size_px = .{ .width = 9, .height = 18 },
|
||||
});
|
||||
try testing.expectEqual(@as(usize, 0), S.calls);
|
||||
}
|
||||
|
||||
test "resize failure preserves terminal state and does not write" {
|
||||
var failing = testing.FailingAllocator.init(testing.allocator, .{});
|
||||
const alloc = failing.allocator();
|
||||
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 1 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
const S = struct {
|
||||
var called: bool = false;
|
||||
|
||||
fn writePty(_: *Handler, _: [:0]const u8) void {
|
||||
called = true;
|
||||
}
|
||||
};
|
||||
S.called = false;
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
var s: Stream = .initAlloc(alloc, handler);
|
||||
defer s.deinit();
|
||||
|
||||
t.modes.set(.synchronized_output, true);
|
||||
t.modes.set(.in_band_size_reports, true);
|
||||
failing.fail_index = failing.alloc_index;
|
||||
try testing.expectError(error.OutOfMemory, s.handler.resize(.{
|
||||
.cols = 513,
|
||||
.rows = 1,
|
||||
.cell_size_px = .{ .width = 9, .height = 18 },
|
||||
}));
|
||||
|
||||
try testing.expect(t.modes.get(.synchronized_output));
|
||||
try testing.expect(!S.called);
|
||||
try testing.expectEqual(@as(@TypeOf(t.cols), 10), t.cols);
|
||||
try testing.expectEqual(@as(u32, 0), t.width_px);
|
||||
try testing.expectEqual(@as(u32, 0), t.height_px);
|
||||
}
|
||||
|
||||
test "resize effects do not change canonical terminal state" {
|
||||
var authoritative: Terminal = try .init(
|
||||
testing.allocator,
|
||||
.{ .cols = 10, .rows = 5 },
|
||||
);
|
||||
defer authoritative.deinit(testing.allocator);
|
||||
var readonly: Terminal = try .init(
|
||||
testing.allocator,
|
||||
.{ .cols = 10, .rows = 5 },
|
||||
);
|
||||
defer readonly.deinit(testing.allocator);
|
||||
|
||||
const S = struct {
|
||||
fn writePty(_: *Handler, _: [:0]const u8) void {}
|
||||
};
|
||||
var authoritative_handler: Handler = .init(&authoritative);
|
||||
authoritative_handler.effects.write_pty = &S.writePty;
|
||||
var authoritative_stream: Stream = .initAlloc(
|
||||
testing.allocator,
|
||||
authoritative_handler,
|
||||
);
|
||||
defer authoritative_stream.deinit();
|
||||
var readonly_stream: Stream = .initAlloc(
|
||||
testing.allocator,
|
||||
.init(&readonly),
|
||||
);
|
||||
defer readonly_stream.deinit();
|
||||
|
||||
authoritative.modes.set(.in_band_size_reports, true);
|
||||
readonly.modes.set(.in_band_size_reports, true);
|
||||
const value: Terminal.Resize = .{
|
||||
.cols = 20,
|
||||
.rows = 10,
|
||||
.cell_size_px = .{ .width = 9, .height = 18 },
|
||||
};
|
||||
try authoritative_stream.handler.resize(value);
|
||||
try readonly_stream.handler.resize(value);
|
||||
|
||||
try testing.expectEqual(authoritative.cols, readonly.cols);
|
||||
try testing.expectEqual(authoritative.rows, readonly.rows);
|
||||
try testing.expectEqual(authoritative.width_px, readonly.width_px);
|
||||
try testing.expectEqual(authoritative.height_px, readonly.height_px);
|
||||
try testing.expect(std.meta.eql(authoritative.modes, readonly.modes));
|
||||
try testing.expect(std.meta.eql(
|
||||
authoritative.scrolling_region,
|
||||
readonly.scrolling_region,
|
||||
));
|
||||
}
|
||||
|
||||
test "basic print" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
@@ -479,18 +479,16 @@ pub fn resize(
|
||||
// Update the size of our terminal state
|
||||
try self.terminal.resize(
|
||||
self.alloc,
|
||||
grid_size.columns,
|
||||
grid_size.rows,
|
||||
.{
|
||||
.cols = grid_size.columns,
|
||||
.rows = grid_size.rows,
|
||||
.cell_size_px = .{
|
||||
.width = self.size.cell.width,
|
||||
.height = self.size.cell.height,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Update our pixel sizes
|
||||
self.terminal.width_px = grid_size.columns * self.size.cell.width;
|
||||
self.terminal.height_px = grid_size.rows * self.size.cell.height;
|
||||
|
||||
// Disable synchronized output mode so that we show changes
|
||||
// immediately for a resize. This is allowed by the spec.
|
||||
self.terminal.modes.set(.synchronized_output, false);
|
||||
|
||||
// If we have size reporting enabled we need to send a report.
|
||||
if (self.terminal.modes.get(.in_band_size_reports)) {
|
||||
try self.sizeReportLocked(td, .mode_2048);
|
||||
|
||||
@@ -747,8 +747,10 @@ pub const StreamHandler = struct {
|
||||
const grid_size = self.size.grid();
|
||||
self.terminal.resize(
|
||||
self.alloc,
|
||||
grid_size.columns,
|
||||
grid_size.rows,
|
||||
.{
|
||||
.cols = grid_size.columns,
|
||||
.rows = grid_size.rows,
|
||||
},
|
||||
) catch |err| {
|
||||
log.err("error updating terminal size: {}", .{err});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user