From 89b103dd5ec669318de53fa361195d155b9e7155 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 17 Jul 2026 08:20:16 -0700 Subject: [PATCH] terminal: more full featured resize (cell geo, sync rendering, etc.) This makes the Terminal.resize handle more of the common elements that a core terminal emulator should: cell geoemtry handling (if exists), updates synchronized output modes. This adds a new TerminalStream.resize that also handles the side effects for more easy integration into downstream libghostty-vt consumers, namely mode 2048 in-band signaling handling. --- src/terminal/Terminal.zig | 193 ++++++++++++++++++++++----- src/terminal/c/terminal.zig | 49 +++---- src/terminal/stream_terminal.zig | 217 +++++++++++++++++++++++++++++++ src/termio/Termio.zig | 18 ++- src/termio/stream_handler.zig | 6 +- 5 files changed, 408 insertions(+), 75 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 089201276..f81ff5356 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3519,35 +3519,80 @@ 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, +}; + /// 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 before grid resize work. +/// +/// If this returns an error, the terminal is in a bad state currently. +/// We will make this safe soon. 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 { + // 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; + + // If we have cell geometry, set that now. + 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; // Resize our tabstops - if (self.cols != cols) { + if (self.cols != opts.cols) { const tabstops: Tabstops = try .init( alloc, - cols, + opts.cols, TABSTOP_INTERVAL, ); self.tabstops.deinit(alloc); @@ -3557,16 +3602,16 @@ pub fn resize( // Resize primary screen, which supports reflow 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, + .cols = opts.cols, + .rows = opts.rows, .reflow = false, }); @@ -3574,18 +3619,101 @@ pub fn resize( 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,7 +3721,10 @@ 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)); @@ -11819,7 +11950,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 +13968,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 +13985,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 +14006,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 +14026,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 +14050,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 +14071,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 +14096,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 +14137,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(); { diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 1c3b9f0c5..da48ab73c 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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)); } diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 91cfb21cf..d7baf528b 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -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,191 @@ 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 resets synchronized output but 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); +} + +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); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 1b08c5e9a..f5d9580eb 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -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); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 43c5d6117..2438003f5 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -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}); };