vt: handle pixel sizes and size reports in ghostty_terminal_resize (#11818)

The resize function now requires cell_width_px and cell_height_px
parameters and handles the full resize sequence: computing and setting
width_px/height_px on the terminal, clearing synchronized output mode so
changes display immediately, and encoding a mode 2048 in-band size
report via the write_pty callback when that mode is enabled.

A valid width/height px is critical for some applications and protocols
and some applications rely directly on in-band size reports, so this
change is necessary to support those use cases.

I do wonder if for the Zig API we should be doing this in
`terminal.resize` or somewhere else, because as it stands this has to
all be manually done on the Zig side.
This commit is contained in:
Mitchell Hashimoto
2026-03-24 14:30:14 -07:00
committed by GitHub
2 changed files with 250 additions and 6 deletions

View File

@@ -570,6 +570,24 @@ typedef enum {
* Output type: size_t *
*/
GHOSTTY_TERMINAL_DATA_SCROLLBACK_ROWS = 15,
/**
* The total width of the terminal in pixels.
*
* This is cols * cell_width_px as set by ghostty_terminal_resize().
*
* Output type: uint32_t *
*/
GHOSTTY_TERMINAL_DATA_WIDTH_PX = 16,
/**
* The total height of the terminal in pixels.
*
* This is rows * cell_height_px as set by ghostty_terminal_resize().
*
* Output type: uint32_t *
*/
GHOSTTY_TERMINAL_DATA_HEIGHT_PX = 17,
} GhosttyTerminalData;
/**
@@ -618,16 +636,25 @@ void ghostty_terminal_reset(GhosttyTerminal terminal);
* screen will reflow content if wraparound mode is enabled; the alternate
* screen does not reflow. If the dimensions are unchanged, this is a no-op.
*
* This also updates the terminal's pixel dimensions (used for image
* protocols and size reports), disables synchronized output mode (allowed
* by the spec so that resize results are shown immediately), and sends an
* in-band size report if mode 2048 is enabled.
*
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param cols New width in cells (must be greater than zero)
* @param rows New height in cells (must be greater than zero)
* @param cell_width_px Width of a single cell in pixels
* @param cell_height_px Height of a single cell in pixels
* @return GHOSTTY_SUCCESS on success, or an error code on failure
*
* @ingroup terminal
*/
GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal,
uint16_t cols,
uint16_t rows);
uint16_t rows,
uint32_t cell_width_px,
uint32_t cell_height_px);
/**
* Set an option on the terminal.

View File

@@ -387,10 +387,39 @@ pub fn resize(
terminal_: Terminal,
cols: size.CellCountInt,
rows: size.CellCountInt,
cell_width_px: u32,
cell_height_px: u32,
) callconv(.c) Result {
const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
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);
}
return .success;
}
@@ -447,6 +476,8 @@ pub const TerminalData = enum(c_int) {
pwd = 13,
total_rows = 14,
scrollback_rows = 15,
width_px = 16,
height_px = 17,
/// Output type expected for querying the data of the given kind.
pub fn OutType(comptime self: TerminalData) type {
@@ -460,6 +491,7 @@ pub const TerminalData = enum(c_int) {
.cursor_style => style_c.Style,
.title, .pwd => lib.String,
.total_rows, .scrollback_rows => usize,
.width_px, .height_px => u32,
};
}
};
@@ -518,6 +550,8 @@ fn getTyped(
},
.total_rows => out.* = t.screens.active.pages.total_rows,
.scrollback_rows => out.* = t.screens.active.pages.total_rows - t.rows,
.width_px => out.* = t.width_px,
.height_px => out.* = t.height_px,
}
return .success;
@@ -692,13 +726,13 @@ test "resize" {
));
defer free(t);
try testing.expectEqual(Result.success, resize(t, 40, 12));
try testing.expectEqual(Result.success, resize(t, 40, 12, 9, 18));
try testing.expectEqual(40, t.?.terminal.cols);
try testing.expectEqual(12, t.?.terminal.rows);
}
test "resize null" {
try testing.expectEqual(Result.invalid_value, resize(null, 80, 24));
try testing.expectEqual(Result.invalid_value, resize(null, 80, 24, 9, 18));
}
test "resize invalid value" {
@@ -714,8 +748,8 @@ test "resize invalid value" {
));
defer free(t);
try testing.expectEqual(Result.invalid_value, resize(t, 0, 24));
try testing.expectEqual(Result.invalid_value, resize(t, 80, 0));
try testing.expectEqual(Result.invalid_value, resize(t, 0, 24, 9, 18));
try testing.expectEqual(Result.invalid_value, resize(t, 80, 0, 9, 18));
}
test "mode_get and mode_set" {
@@ -1840,6 +1874,189 @@ test "get title set via vt_write" {
try testing.expectEqualStrings("VT Title", title.ptr[0..title.len]);
}
test "resize updates pixel dimensions" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib_alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
try testing.expectEqual(Result.success, resize(t, 100, 40, 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);
}
test "resize pixel overflow saturates" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib_alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
try testing.expectEqual(Result.success, resize(t, 100, 40, std.math.maxInt(u32), std.math.maxInt(u32)));
const zt = t.?.terminal;
try testing.expectEqual(std.math.maxInt(u32), zt.width_px);
try testing.expectEqual(std.math.maxInt(u32), zt.height_px);
}
test "resize disables synchronized output" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib_alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
const zt = t.?.terminal;
zt.modes.set(.synchronized_output, true);
try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18));
try testing.expect(!zt.modes.get(.synchronized_output));
}
test "resize sends in-band size report" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib_alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
// Enable in-band size reports (mode 2048)
t.?.terminal.modes.set(.in_band_size_reports, true);
try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18));
// Expected: \x1B[48;rows;cols;height_px;width_pxt
// height_px = 40*18 = 720, width_px = 100*9 = 900
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("\x1B[48;40;100;720;900t", S.last_data.?);
}
test "resize no size report without mode 2048" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib_alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
const S = struct {
var called: bool = false;
fn writePty(_: Terminal, _: ?*anyopaque, _: [*]const u8, _: usize) callconv(.c) void {
called = true;
}
};
S.called = false;
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
// in_band_size_reports is off by default
try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18));
try testing.expect(!S.called);
}
test "resize in-band report without write_pty callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib_alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
// Enable mode 2048 but don't set a write_pty callback — should not crash
t.?.terminal.modes.set(.in_band_size_reports, true);
try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18));
}
test "resize null terminal" {
try testing.expectEqual(Result.invalid_value, resize(null, 100, 40, 9, 18));
}
test "resize zero cols" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib_alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
try testing.expectEqual(Result.invalid_value, resize(t, 0, 40, 9, 18));
}
test "resize zero rows" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib_alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
try testing.expectEqual(Result.invalid_value, resize(t, 100, 0, 9, 18));
}
test "grid_ref out of bounds" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(