mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-26 09:01:44 +00:00
fix(lib-vt): report size when mode 2048 is enabled (#13885)
Mode 2048 requires terminals to report the current rows, columns, and pixel dimensions [when the mode is enabled](https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83#:~:text=When%20first%20enabled%2C%20the%20terminal%20MUST%20send%20a%20report%20of%20the%20current%20size.), then report updated geometry after later resizes. The native termio stream already queues the initial report, but the terminal stream only reported from `resize`, so libghostty consumers that enabled the mode after committing geometry received nothing until another resize. This makes the generic handler match termio by requesting current geometry through the existing size callback and writing the encoded report through `write_pty` on every enable.
This commit is contained in:
@@ -86,13 +86,13 @@ extern "C" {
|
||||
*
|
||||
* | Option | Callback Type | Trigger |
|
||||
* |-----------------------------------------|-----------------------------------|-------------------------------------------|
|
||||
* | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty |
|
||||
* | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | VT query and mode reports written back to the PTY |
|
||||
* | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) |
|
||||
* | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 |
|
||||
* | `GHOSTTY_TERMINAL_OPT_PWD_CHANGED` | `GhosttyTerminalPwdChangedFn` | Pwd change via OSC 7 / OSC 9 / OSC 1337 |
|
||||
* | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) |
|
||||
* | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) |
|
||||
* | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) |
|
||||
* | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS query (CSI 14/16/18 t) or mode 2048 enable |
|
||||
* | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) |
|
||||
* | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)|
|
||||
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 |
|
||||
@@ -688,16 +688,18 @@ typedef GhosttyString (*GhosttyTerminalEnquiryFn)(GhosttyTerminal terminal,
|
||||
void* userdata);
|
||||
|
||||
/**
|
||||
* Callback function type for size queries (XTWINOPS).
|
||||
* Callback function type for terminal size reports.
|
||||
*
|
||||
* Called in response to XTWINOPS size queries (CSI 14/16/18 t).
|
||||
* Called in response to XTWINOPS size queries (CSI 14/16/18 t) and when VT
|
||||
* input enables in-band size reports (mode 2048).
|
||||
* Return true and fill *out_size with the current terminal geometry,
|
||||
* or return false to silently ignore the query.
|
||||
* or return false to suppress the report.
|
||||
*
|
||||
* @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
|
||||
* @return true if size was filled, false to suppress the XTWINOPS response or
|
||||
* mode 2048 report
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
@@ -749,9 +751,9 @@ typedef void (*GhosttyTerminalPwdChangedFn)(GhosttyTerminal terminal,
|
||||
* Callback function type for write_pty.
|
||||
*
|
||||
* Called when the terminal needs to write data back to the pty, for
|
||||
* example in response to a device status report or mode query. The
|
||||
* data is only valid for the duration of the call; callers must copy
|
||||
* it if it needs to persist.
|
||||
* example in response to a device status report, mode query, or VT-driven
|
||||
* mode 2048 enable. The data is only valid for the duration of the call;
|
||||
* callers must copy it if it needs to persist.
|
||||
*
|
||||
* @param terminal The terminal handle
|
||||
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
|
||||
@@ -818,8 +820,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
|
||||
/**
|
||||
* Callback invoked when the terminal needs to write data back
|
||||
* to the pty (e.g. in response to a DECRQM query or device
|
||||
* status report). Set to NULL to ignore such sequences.
|
||||
* to the pty (e.g. in response to a DECRQM query, device status
|
||||
* report, or VT-driven mode 2048 enable). Set to NULL to ignore such
|
||||
* sequences.
|
||||
*
|
||||
* Input type: GhosttyTerminalWritePtyFn
|
||||
*/
|
||||
|
||||
@@ -272,9 +272,10 @@ const Effects = struct {
|
||||
/// and its content are borrowed for the callback duration.
|
||||
pub const UnknownSequenceFn = *const fn (Terminal, ?*anyopaque, *const UnknownSequence.C) callconv(lib.calling_conv) 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.
|
||||
/// C function pointer type for the size callback. Used by XTWINOPS queries
|
||||
/// and VT-driven mode 2048 enable reports. Returns true and fills out_size
|
||||
/// if size is available, or false to suppress the XTWINOPS response or
|
||||
/// mode 2048 report.
|
||||
pub const SizeFn = *const fn (Terminal, ?*anyopaque, *size_report.Size) callconv(lib.calling_conv) bool;
|
||||
|
||||
/// C function pointer type for the device_attributes callback.
|
||||
@@ -4673,6 +4674,109 @@ test "size without callback is silent" {
|
||||
vt_write(t, "\x1B[18t", 5);
|
||||
}
|
||||
|
||||
test "mode 2048 enable and disable use C callbacks" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
const S = struct {
|
||||
var data: [128]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
var calls: usize = 0;
|
||||
|
||||
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, length: usize) callconv(lib.calling_conv) void {
|
||||
@memcpy(data[0..length], ptr[0..length]);
|
||||
len = length;
|
||||
calls += 1;
|
||||
}
|
||||
|
||||
fn sizeCb(_: Terminal, _: ?*anyopaque, out_size: *size_report.Size) callconv(lib.calling_conv) bool {
|
||||
out_size.* = .{
|
||||
.rows = 24,
|
||||
.columns = 80,
|
||||
.cell_width = 8,
|
||||
.cell_height = 16,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
};
|
||||
S.len = 0;
|
||||
S.calls = 0;
|
||||
|
||||
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
|
||||
try testing.expectEqual(Result.success, set(t, .size_cb, @ptrCast(&S.sizeCb)));
|
||||
|
||||
const enable = "\x1B[?2048h";
|
||||
const disable = "\x1B[?2048l";
|
||||
vt_write(t, enable, enable.len);
|
||||
vt_write(t, enable, enable.len);
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), S.calls);
|
||||
try testing.expectEqualStrings("\x1B[48;24;80;384;640t", S.data[0..S.len]);
|
||||
vt_write(t, disable, disable.len);
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), S.calls);
|
||||
try testing.expect(!t.?.terminal.modes.get(.in_band_size_reports));
|
||||
}
|
||||
|
||||
test "mode 2048 enable tolerates missing C callbacks" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
const S = struct {
|
||||
var writes: usize = 0;
|
||||
var sizes: usize = 0;
|
||||
|
||||
fn writePty(_: Terminal, _: ?*anyopaque, _: [*]const u8, _: usize) callconv(lib.calling_conv) void {
|
||||
writes += 1;
|
||||
}
|
||||
|
||||
fn sizeCb(_: Terminal, _: ?*anyopaque, out_size: *size_report.Size) callconv(lib.calling_conv) bool {
|
||||
sizes += 1;
|
||||
out_size.* = .{
|
||||
.rows = 24,
|
||||
.columns = 80,
|
||||
.cell_width = 8,
|
||||
.cell_height = 16,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
};
|
||||
S.writes = 0;
|
||||
S.sizes = 0;
|
||||
|
||||
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
|
||||
|
||||
const sequence = "\x1B[?2048h";
|
||||
vt_write(t, sequence, sequence.len);
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), S.writes);
|
||||
try testing.expect(t.?.terminal.modes.get(.in_band_size_reports));
|
||||
var no_write: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&no_write,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(no_write);
|
||||
try testing.expectEqual(Result.success, set(no_write, .size_cb, @ptrCast(&S.sizeCb)));
|
||||
vt_write(no_write, sequence, sequence.len);
|
||||
try testing.expectEqual(@as(usize, 1), S.sizes);
|
||||
try testing.expect(no_write.?.terminal.modes.get(.in_band_size_reports));
|
||||
}
|
||||
|
||||
test "set device_attributes callback primary" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
|
||||
@@ -118,9 +118,10 @@ pub const Handler = struct {
|
||||
/// valid for the lifetime of the call.
|
||||
enquiry: ?*const fn (*Handler) []const u8,
|
||||
|
||||
/// Called in response to XTWINOPS size queries (CSI 14/16/18 t).
|
||||
/// Returns the current terminal geometry used for encoding.
|
||||
/// Return null to silently ignore the query.
|
||||
/// Called for XTWINOPS size queries (CSI 14/16/18 t) and when VT input
|
||||
/// enables in-band size reports (mode 2048). Returns the current
|
||||
/// terminal geometry used for encoding. Return null to suppress the
|
||||
/// XTWINOPS response or mode 2048 report.
|
||||
size: ?*const fn (*Handler) ?size_report.Size,
|
||||
|
||||
/// Called when the terminal title changes via escape sequences
|
||||
@@ -674,6 +675,17 @@ pub const Handler = struct {
|
||||
self.writePty(resp);
|
||||
}
|
||||
|
||||
fn reportMode2048(self: *Handler) void {
|
||||
const get_size = self.effects.size orelse return;
|
||||
const current = get_size(self) orelse return;
|
||||
|
||||
var buf: [128]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(buf[0 .. buf.len - 1]);
|
||||
size_report.encode(&writer, .mode_2048, current) catch return;
|
||||
buf[writer.end] = 0;
|
||||
self.writePty(buf[0..writer.end :0]);
|
||||
}
|
||||
|
||||
fn windowTitle(self: *Handler, title_raw: []const u8) !void {
|
||||
// Prevent DoS attacks by limiting title length.
|
||||
const max_title_len = 1024;
|
||||
@@ -797,10 +809,14 @@ pub const Handler = struct {
|
||||
|
||||
.synchronized_output,
|
||||
.linefeed,
|
||||
.in_band_size_reports,
|
||||
.focus_event,
|
||||
=> {},
|
||||
|
||||
// Enabling mode 2048 reports already-committed pixel geometry.
|
||||
// Waiting for the next resize leaves late-enabling clients without
|
||||
// the dimensions they need for their first image frame.
|
||||
.in_band_size_reports => if (enabled) self.reportMode2048(),
|
||||
|
||||
.report_visibility => if (enabled) self.sendVisibilityReport(),
|
||||
|
||||
.mouse_event_x10 => {
|
||||
@@ -2909,6 +2925,97 @@ test "size report csi_14_t with effect" {
|
||||
try testing.expectEqualStrings("\x1b[4;432;720t", S.written.?);
|
||||
}
|
||||
|
||||
test "mode 2048 enable reports current geometry and disable is silent" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = struct {
|
||||
var response: [128]u8 = undefined;
|
||||
var response_len: usize = 0;
|
||||
var calls: usize = 0;
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
@memcpy(response[0..data.len], data);
|
||||
response_len = data.len;
|
||||
calls += 1;
|
||||
}
|
||||
|
||||
fn getSize(_: *Handler) ?size_report.Size {
|
||||
return .{ .rows = 24, .columns = 80, .cell_width = 8, .cell_height = 16 };
|
||||
}
|
||||
};
|
||||
S.response_len = 0;
|
||||
S.calls = 0;
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
handler.effects.size = &S.getSize;
|
||||
|
||||
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
|
||||
defer s.deinit();
|
||||
|
||||
s.nextSlice("\x1b[?2048h");
|
||||
s.nextSlice("\x1b[?2048h");
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), S.calls);
|
||||
try testing.expectEqualStrings("\x1b[48;24;80;384;640t", S.response[0..S.response_len]);
|
||||
|
||||
s.nextSlice("\x1b[?2048l");
|
||||
try testing.expectEqual(@as(usize, 2), S.calls);
|
||||
try testing.expect(!t.modes.get(.in_band_size_reports));
|
||||
}
|
||||
|
||||
test "mode 2048 enable tolerates missing effects" {
|
||||
const S = struct {
|
||||
var calls: usize = 0;
|
||||
|
||||
fn writePty(_: *Handler, _: [:0]const u8) void {
|
||||
calls += 1;
|
||||
}
|
||||
|
||||
fn getSize(_: *Handler) ?size_report.Size {
|
||||
return .{ .rows = 24, .columns = 80, .cell_width = 8, .cell_height = 16 };
|
||||
}
|
||||
};
|
||||
S.calls = 0;
|
||||
|
||||
var no_size_terminal: Terminal = try .init(
|
||||
testing.io,
|
||||
testing.allocator,
|
||||
.{ .cols = 80, .rows = 24 },
|
||||
);
|
||||
defer no_size_terminal.deinit(testing.allocator);
|
||||
var no_size_handler: Handler = .init(&no_size_terminal);
|
||||
no_size_handler.effects.write_pty = &S.writePty;
|
||||
var no_size_stream: Stream = .init(.{
|
||||
.allocator = testing.allocator,
|
||||
.handler = no_size_handler,
|
||||
});
|
||||
defer no_size_stream.deinit();
|
||||
|
||||
no_size_stream.nextSlice("\x1b[?2048h");
|
||||
try testing.expect(no_size_terminal.modes.get(.in_band_size_reports));
|
||||
try testing.expectEqual(@as(usize, 0), S.calls);
|
||||
|
||||
var no_write_terminal: Terminal = try .init(
|
||||
testing.io,
|
||||
testing.allocator,
|
||||
.{ .cols = 80, .rows = 24 },
|
||||
);
|
||||
defer no_write_terminal.deinit(testing.allocator);
|
||||
var no_write_handler: Handler = .init(&no_write_terminal);
|
||||
no_write_handler.effects.size = &S.getSize;
|
||||
var no_write_stream: Stream = .init(.{
|
||||
.allocator = testing.allocator,
|
||||
.handler = no_write_handler,
|
||||
});
|
||||
defer no_write_stream.deinit();
|
||||
|
||||
no_write_stream.nextSlice("\x1b[?2048h");
|
||||
try testing.expect(no_write_terminal.modes.get(.in_band_size_reports));
|
||||
try testing.expectEqual(@as(usize, 0), S.calls);
|
||||
}
|
||||
|
||||
test "size report csi_16_t with effect" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
Reference in New Issue
Block a user