vt: add size effect callback for XTWINOPS queries

Add GHOSTTY_TERMINAL_OPT_SIZE so C consumers can respond to
XTWINOPS size queries (CSI 14/16/18 t). The callback receives a
GhosttySizeReportSize out-pointer and returns true if the size is
available, or false to silently ignore the query. The trampoline
converts the bool + out-pointer pattern to the optional that the
Zig handler expects.
This commit is contained in:
Mitchell Hashimoto
2026-03-24 07:09:50 -07:00
parent 6f18d44ed6
commit 424e9b57ca
2 changed files with 113 additions and 0 deletions

View File

@@ -13,6 +13,7 @@
#include <ghostty/vt/types.h>
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/modes.h>
#include <ghostty/vt/size_report.h>
#include <ghostty/vt/grid_ref.h>
#include <ghostty/vt/screen.h>
#include <ghostty/vt/point.h>
@@ -216,6 +217,24 @@ typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal,
typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal,
void* userdata);
/**
* Callback function type for size queries (XTWINOPS).
*
* Called in response to XTWINOPS size queries (CSI 14/16/18 t).
* Return true and fill *out_size with the current terminal geometry,
* or return false to silently ignore the query.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
* @param[out] out_size Pointer to store the terminal size information
* @return true if size was filled, false to ignore the query
*
* @ingroup terminal
*/
typedef bool (*GhosttyTerminalSizeFn)(GhosttyTerminal terminal,
void* userdata,
GhosttySizeReportSize* out_size);
/**
* Terminal option identifiers.
*
@@ -273,6 +292,14 @@ typedef enum {
* Input type: GhosttyTerminalTitleChangedFn*
*/
GHOSTTY_TERMINAL_OPT_TITLE_CHANGED = 5,
/**
* Callback invoked in response to XTWINOPS size queries
* (CSI 14/16/18 t). Set to NULL to silently ignore size queries.
*
* Input type: GhosttyTerminalSizeFn*
*/
GHOSTTY_TERMINAL_OPT_SIZE = 6,
} GhosttyTerminalOption;
/**

View File

@@ -11,6 +11,7 @@ const kitty = @import("../kitty/key.zig");
const modes = @import("../modes.zig");
const point = @import("../point.zig");
const size = @import("../size.zig");
const size_report = @import("../size_report.zig");
const cell_c = @import("cell.zig");
const row_c = @import("row.zig");
const grid_ref_c = @import("grid_ref.zig");
@@ -40,6 +41,7 @@ const Effects = struct {
enquiry: ?EnquiryFn = null,
xtversion: ?XtversionFn = null,
title_changed: ?TitleChangedFn = null,
size_cb: ?SizeFn = null,
/// C function pointer type for the write_pty callback.
pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(.c) void;
@@ -61,6 +63,11 @@ const Effects = struct {
/// C function pointer type for the title_changed callback.
pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(.c) void;
/// C function pointer type for the size callback.
/// Returns true and fills out_size if size is available,
/// or returns false to silently ignore the query.
pub const SizeFn = *const fn (Terminal, ?*anyopaque, *size_report.Size) callconv(.c) bool;
fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void {
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
@@ -99,6 +106,15 @@ const Effects = struct {
const func = wrapper.effects.title_changed orelse return;
func(@ptrCast(wrapper), wrapper.effects.userdata);
}
fn sizeTrampoline(handler: *Handler) ?size_report.Size {
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
const func = wrapper.effects.size_cb orelse return null;
var s: size_report.Size = undefined;
if (func(@ptrCast(wrapper), wrapper.effects.userdata, &s)) return s;
return null;
}
};
/// C: GhosttyTerminal
@@ -163,6 +179,7 @@ fn new_(
handler.effects.enquiry = &Effects.enquiryTrampoline;
handler.effects.xtversion = &Effects.xtversionTrampoline;
handler.effects.title_changed = &Effects.titleChangedTrampoline;
handler.effects.size = &Effects.sizeTrampoline;
wrapper.* = .{
.terminal = t,
@@ -189,6 +206,7 @@ pub const Option = enum(c_int) {
enquiry = 3,
xtversion = 4,
title_changed = 5,
size_cb = 6,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -199,6 +217,7 @@ pub const Option = enum(c_int) {
.enquiry => ?Effects.EnquiryFn,
.xtversion => ?Effects.XtversionFn,
.title_changed => ?Effects.TitleChangedFn,
.size_cb => ?Effects.SizeFn,
};
}
};
@@ -237,6 +256,7 @@ fn setTyped(
.enquiry => wrapper.effects.enquiry = if (value) |v| v.* else null,
.xtversion => wrapper.effects.xtversion = if (value) |v| v.* else null,
.title_changed => wrapper.effects.title_changed = if (value) |v| v.* else null,
.size_cb => wrapper.effects.size_cb = if (value) |v| v.* else null,
}
}
@@ -1271,6 +1291,72 @@ test "title_changed without callback is silent" {
vt_write(t, "\x1B]2;Hello\x1B\\", 10);
}
test "set size 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);
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");
}
fn sizeCb(_: Terminal, _: ?*anyopaque, out_size: *size_report.Size) callconv(.c) bool {
out_size.* = .{
.rows = 24,
.columns = 80,
.cell_width = 8,
.cell_height = 16,
};
return true;
}
};
defer S.deinit();
const write_cb: ?Effects.WritePtyFn = &S.writePty;
set(t, .write_pty, @ptrCast(&write_cb));
const size_cb_fn: ?Effects.SizeFn = &S.sizeCb;
set(t, .size_cb, @ptrCast(&size_cb_fn));
// CSI 18 t — report text area size in characters
vt_write(t, "\x1B[18t", 5);
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("\x1b[8;24;80t", S.last_data.?);
}
test "size without callback is silent" {
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);
// CSI 18 t without a size callback should not crash
vt_write(t, "\x1B[18t", 5);
}
test "grid_ref out of bounds" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(