mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-31 12:49:03 +00:00
lib-vt: handle DECRQSS
Move DECRQSS response encoding into the terminal DCS handler so both the full termio path and libghostty-vt terminal stream emit the same replies. The C API stream now maintains and releases DCS parser state and forwards responses through write_pty.
This commit is contained in:
@@ -2471,6 +2471,41 @@ test "set write_pty callback" {
|
||||
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
|
||||
}
|
||||
|
||||
test "write_pty receives DECRQSS response" {
|
||||
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) |data| testing.allocator.free(data);
|
||||
last_data = null;
|
||||
}
|
||||
|
||||
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
|
||||
if (last_data) |data| testing.allocator.free(data);
|
||||
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)));
|
||||
|
||||
const query = "\x1B[1m\x1BP$qm\x1B\\";
|
||||
vt_write(t, query, query.len);
|
||||
try testing.expectEqualStrings("\x1BP1$r0;1m\x1B\\", S.last_data.?);
|
||||
}
|
||||
|
||||
test "write_pty receives OSC color query response" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
|
||||
@@ -254,6 +254,63 @@ pub const Command = union(enum) {
|
||||
decscusr,
|
||||
decstbm,
|
||||
decslrm,
|
||||
|
||||
/// Fixed upper bound for an encoded DECRPSS response. The comptime
|
||||
/// calculated max at the time of writing this was around 63 so this
|
||||
/// leaves a ton of space for future stuff. We don't do the comptime
|
||||
/// calculation cause it complicated the implementation a bit too
|
||||
/// much.
|
||||
pub const max_response_bytes = 256;
|
||||
|
||||
/// Encode the response for this request.
|
||||
pub fn encode(
|
||||
self: DECRQSS,
|
||||
t: *terminal.Terminal,
|
||||
response: []u8,
|
||||
) ![]const u8 {
|
||||
var writer: std.Io.Writer = .fixed(response);
|
||||
|
||||
const prefix_fmt = "\x1bP{d}$r";
|
||||
const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len;
|
||||
writer.end = prefix_len;
|
||||
|
||||
switch (self) {
|
||||
.none => {},
|
||||
.sgr => {
|
||||
const buf = try t.printAttributes(writer.buffer[writer.end..]);
|
||||
writer.end += buf.len;
|
||||
try writer.writeByte('m');
|
||||
},
|
||||
.decscusr => {
|
||||
const blink = t.modes.get(.cursor_blinking);
|
||||
const style: u8 = switch (t.screens.active.cursor.cursor_style) {
|
||||
.block, .block_hollow => if (blink) 1 else 2,
|
||||
.underline => if (blink) 3 else 4,
|
||||
.bar => if (blink) 5 else 6,
|
||||
};
|
||||
try writer.print("{d} q", .{style});
|
||||
},
|
||||
.decstbm => try writer.print("{d};{d}r", .{
|
||||
t.scrolling_region.top + 1,
|
||||
t.scrolling_region.bottom + 1,
|
||||
}),
|
||||
.decslrm => if (t.modes.get(.enable_left_and_right_margin)) {
|
||||
try writer.print("{d};{d}s", .{
|
||||
t.scrolling_region.left + 1,
|
||||
t.scrolling_region.right + 1,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
const valid = writer.end > prefix_len;
|
||||
try writer.writeAll("\x1b\\");
|
||||
_ = try std.fmt.bufPrint(
|
||||
response[0..prefix_len],
|
||||
prefix_fmt,
|
||||
.{@intFromBool(valid)},
|
||||
);
|
||||
return writer.buffered();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -405,6 +462,89 @@ test "DECRQSS invalid command" {
|
||||
try testing.expect(h.unhook() == null);
|
||||
}
|
||||
|
||||
test "DECRQSS response encoding" {
|
||||
const testing = std.testing;
|
||||
|
||||
var t: terminal.Terminal = try .init(
|
||||
testing.io,
|
||||
testing.allocator,
|
||||
.{ .cols = 80, .rows = 24 },
|
||||
);
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = struct {
|
||||
fn expectResponse(
|
||||
term: *terminal.Terminal,
|
||||
request: Command.DECRQSS,
|
||||
expected: []const u8,
|
||||
) !void {
|
||||
var buf: [Command.DECRQSS.max_response_bytes]u8 = undefined;
|
||||
const encoded = try request.encode(term, &buf);
|
||||
try testing.expectEqualStrings(expected, encoded);
|
||||
}
|
||||
};
|
||||
|
||||
try S.expectResponse(&t, .none, "\x1BP0$r\x1B\\");
|
||||
try S.expectResponse(&t, .sgr, "\x1BP1$r0m\x1B\\");
|
||||
|
||||
try t.setAttribute(.bold);
|
||||
try t.setAttribute(.{ .underline = .curly });
|
||||
try S.expectResponse(&t, .sgr, "\x1BP1$r0;1;4:3m\x1B\\");
|
||||
|
||||
t.setCursorStyle(.steady_underline);
|
||||
try S.expectResponse(&t, .decscusr, "\x1BP1$r4 q\x1B\\");
|
||||
|
||||
t.scrolling_region.top = 4;
|
||||
t.scrolling_region.bottom = 19;
|
||||
try S.expectResponse(&t, .decstbm, "\x1BP1$r5;20r\x1B\\");
|
||||
|
||||
try S.expectResponse(&t, .decslrm, "\x1BP0$r\x1B\\");
|
||||
t.modes.set(.enable_left_and_right_margin, true);
|
||||
t.scrolling_region.left = 2;
|
||||
t.scrolling_region.right = 69;
|
||||
try S.expectResponse(&t, .decslrm, "\x1BP1$r3;70s\x1B\\");
|
||||
}
|
||||
|
||||
test "DECRQSS largest response fits fixed buffer" {
|
||||
const testing = std.testing;
|
||||
|
||||
var t: terminal.Terminal = try .init(
|
||||
testing.io,
|
||||
testing.allocator,
|
||||
.{ .cols = 80, .rows = 24 },
|
||||
);
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
try t.setAttribute(.bold);
|
||||
try t.setAttribute(.faint);
|
||||
try t.setAttribute(.italic);
|
||||
try t.setAttribute(.{ .underline = .dashed });
|
||||
try t.setAttribute(.blink);
|
||||
try t.setAttribute(.inverse);
|
||||
try t.setAttribute(.invisible);
|
||||
try t.setAttribute(.strikethrough);
|
||||
try t.setAttribute(.{ .direct_color_fg = .{
|
||||
.r = 255,
|
||||
.g = 255,
|
||||
.b = 255,
|
||||
} });
|
||||
try t.setAttribute(.{ .direct_color_bg = .{
|
||||
.r = 255,
|
||||
.g = 255,
|
||||
.b = 255,
|
||||
} });
|
||||
|
||||
var buf: [Command.DECRQSS.max_response_bytes]u8 = undefined;
|
||||
const encoded = try Command.DECRQSS.sgr.encode(&t, &buf);
|
||||
|
||||
const expected =
|
||||
"\x1BP1$r0;1;2;3;4:5;5;7;8;9" ++
|
||||
";38:2::255:255:255" ++
|
||||
";48:2::255:255:255m\x1B\\";
|
||||
try testing.expectEqualStrings(expected, encoded);
|
||||
try testing.expect(encoded.len <= Command.DECRQSS.max_response_bytes);
|
||||
}
|
||||
|
||||
test "tmux enter and implicit exit" {
|
||||
if (comptime !build_options.tmux_control_mode) return error.SkipZigTest;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const testing = std.testing;
|
||||
const apc = @import("apc.zig");
|
||||
const clipboard = @import("clipboard.zig");
|
||||
const csi = @import("csi.zig");
|
||||
const dcs = @import("dcs.zig");
|
||||
const device_attributes = @import("device_attributes.zig");
|
||||
const device_status = @import("device_status.zig");
|
||||
const stream = @import("stream.zig");
|
||||
@@ -60,6 +61,9 @@ pub const Handler = struct {
|
||||
/// the kitty graphics protocol.
|
||||
apc_handler: apc.Handler = .{},
|
||||
|
||||
/// The DCS command handler maintains state for DCS queries.
|
||||
dcs_handler: dcs.Handler = .{},
|
||||
|
||||
pub const Effects = struct {
|
||||
/// Called when the terminal needs to write data back to the pty,
|
||||
/// e.g. in response to a DECRQM query. The data is only valid
|
||||
@@ -145,6 +149,7 @@ pub const Handler = struct {
|
||||
|
||||
pub fn deinit(self: *Handler) void {
|
||||
self.apc_handler.deinit();
|
||||
self.dcs_handler.deinit();
|
||||
}
|
||||
|
||||
/// Resize the terminal and apply any side effects (if supported)
|
||||
@@ -327,12 +332,9 @@ pub const Handler = struct {
|
||||
log.warn("error handling clipboard write err={}", .{err});
|
||||
},
|
||||
|
||||
// No supported DCS commands have any terminal-modifying effects,
|
||||
// but they may in the future. For now we just ignore it.
|
||||
.dcs_hook,
|
||||
.dcs_put,
|
||||
.dcs_unhook,
|
||||
=> {},
|
||||
.dcs_hook => try self.dcsHook(value),
|
||||
.dcs_put => try self.dcsPut(value),
|
||||
.dcs_unhook => try self.dcsUnhook(),
|
||||
|
||||
// Have no terminal-modifying effect
|
||||
.show_desktop_notification,
|
||||
@@ -348,6 +350,47 @@ pub const Handler = struct {
|
||||
func(self, data);
|
||||
}
|
||||
|
||||
fn dcsHook(self: *Handler, value: Action.Value(.dcs_hook)) !void {
|
||||
var cmd = self.dcs_handler.hook(
|
||||
self.terminal.gpa(),
|
||||
value,
|
||||
) orelse return;
|
||||
defer cmd.deinit();
|
||||
try self.dcsCommand(&cmd);
|
||||
}
|
||||
|
||||
fn dcsPut(self: *Handler, value: u8) !void {
|
||||
var cmd = self.dcs_handler.put(value) orelse return;
|
||||
defer cmd.deinit();
|
||||
try self.dcsCommand(&cmd);
|
||||
}
|
||||
|
||||
fn dcsUnhook(self: *Handler) !void {
|
||||
var cmd = self.dcs_handler.unhook() orelse return;
|
||||
defer cmd.deinit();
|
||||
try self.dcsCommand(&cmd);
|
||||
}
|
||||
|
||||
fn dcsCommand(self: *Handler, cmd: *dcs.Command) !void {
|
||||
switch (cmd.*) {
|
||||
.decrqss => |request| {
|
||||
var response: [
|
||||
dcs.Command.DECRQSS.max_response_bytes + 1
|
||||
]u8 = undefined;
|
||||
const encoded = try request.encode(
|
||||
self.terminal,
|
||||
response[0 .. response.len - 1],
|
||||
);
|
||||
response[encoded.len] = 0;
|
||||
self.writePty(response[0..encoded.len :0]);
|
||||
},
|
||||
|
||||
.tmux,
|
||||
.xtgettcap,
|
||||
=> {},
|
||||
}
|
||||
}
|
||||
|
||||
fn bell(self: *Handler) void {
|
||||
const func = self.effects.bell orelse return;
|
||||
func(self);
|
||||
@@ -1314,6 +1357,94 @@ test "attributes" {
|
||||
try testing.expectEqualStrings("Bold", str);
|
||||
}
|
||||
|
||||
test "DECRQSS responses" {
|
||||
var t: Terminal = try .init(
|
||||
testing.io,
|
||||
testing.allocator,
|
||||
.{ .cols = 80, .rows = 24 },
|
||||
);
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = struct {
|
||||
var response: [dcs.Command.DECRQSS.max_response_bytes]u8 = undefined;
|
||||
var response_len: usize = 0;
|
||||
var calls: usize = 0;
|
||||
|
||||
fn reset() void {
|
||||
response_len = 0;
|
||||
calls = 0;
|
||||
}
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
@memcpy(response[0..data.len], data);
|
||||
response_len = data.len;
|
||||
calls += 1;
|
||||
}
|
||||
|
||||
fn expectResponse(expected: []const u8) !void {
|
||||
try testing.expectEqual(@as(usize, 1), calls);
|
||||
try testing.expectEqualStrings(
|
||||
expected,
|
||||
response[0..response_len],
|
||||
);
|
||||
reset();
|
||||
}
|
||||
};
|
||||
S.reset();
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
var s: Stream = .initAlloc(testing.allocator, handler);
|
||||
defer s.deinit();
|
||||
|
||||
// SGR
|
||||
s.nextSlice("\x1B[1m\x1BP$qm\x1B\\");
|
||||
try S.expectResponse("\x1BP1$r0;1m\x1B\\");
|
||||
|
||||
// Requests larger than the parser's fixed request buffer are ignored,
|
||||
// and the next DCS command must still be processed normally.
|
||||
s.nextSlice("\x1BP$qfoo\x1B\\");
|
||||
try testing.expectEqual(@as(usize, 0), S.calls);
|
||||
try testing.expect(!s.handler.semantic_failure);
|
||||
s.nextSlice("\x1BP$qm\x1B\\");
|
||||
try S.expectResponse("\x1BP1$r0;1m\x1B\\");
|
||||
}
|
||||
|
||||
test "DECRQSS without write effect is ignored" {
|
||||
var t: Terminal = try .init(
|
||||
testing.io,
|
||||
testing.allocator,
|
||||
.{ .cols = 80, .rows = 24 },
|
||||
);
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
s.nextSlice("\x1BP$qm\x1B\\");
|
||||
try testing.expect(!s.handler.semantic_failure);
|
||||
}
|
||||
|
||||
test "DCS command memory is released" {
|
||||
var t: Terminal = try .init(
|
||||
testing.io,
|
||||
testing.allocator,
|
||||
.{ .cols = 80, .rows = 24 },
|
||||
);
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
|
||||
// A completed, unsupported command transfers its allocation to Command;
|
||||
// dcsCommand must release it even though stream_terminal ignores it.
|
||||
s.nextSlice("\x1BP+q536D756C78\x1B\\");
|
||||
|
||||
// An incomplete command remains owned by the handler and must be released
|
||||
// when the stream is deinitialized. testing.allocator detects either leak.
|
||||
s.nextSlice("\x1BP+q536D756C78");
|
||||
s.deinit();
|
||||
}
|
||||
|
||||
test "DECALN screen alignment" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 3 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
@@ -464,72 +464,12 @@ pub const StreamHandler = struct {
|
||||
},
|
||||
|
||||
.decrqss => |decrqss| {
|
||||
var response: [128]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&response);
|
||||
|
||||
// Offset the stream position to just past the response prefix.
|
||||
// We will write the "payload" (if any) below. If no payload is
|
||||
// written then we send an invalid DECRPSS response.
|
||||
const prefix_fmt = "\x1bP{d}$r";
|
||||
const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len;
|
||||
writer.end = prefix_len;
|
||||
|
||||
switch (decrqss) {
|
||||
// Invalid or unhandled request
|
||||
.none => {},
|
||||
|
||||
.sgr => {
|
||||
const buf = try self.terminal.printAttributes(writer.buffer[writer.end..]);
|
||||
|
||||
// printAttributes wrote into our buffer, so adjust the stream
|
||||
// position
|
||||
writer.end += buf.len;
|
||||
|
||||
try writer.writeByte('m');
|
||||
},
|
||||
|
||||
.decscusr => {
|
||||
const blink = self.terminal.modes.get(.cursor_blinking);
|
||||
const style: u8 = switch (self.terminal.screens.active.cursor.cursor_style) {
|
||||
.block => if (blink) 1 else 2,
|
||||
.underline => if (blink) 3 else 4,
|
||||
.bar => if (blink) 5 else 6,
|
||||
|
||||
// Below here, the cursor styles aren't represented by
|
||||
// DECSCUSR so we map it to some other style.
|
||||
.block_hollow => if (blink) 1 else 2,
|
||||
};
|
||||
try writer.print("{d} q", .{style});
|
||||
},
|
||||
|
||||
.decstbm => {
|
||||
try writer.print("{d};{d}r", .{
|
||||
self.terminal.scrolling_region.top + 1,
|
||||
self.terminal.scrolling_region.bottom + 1,
|
||||
});
|
||||
},
|
||||
|
||||
.decslrm => {
|
||||
// We only send a valid response when left and right
|
||||
// margin mode (DECLRMM) is enabled.
|
||||
if (self.terminal.modes.get(.enable_left_and_right_margin)) {
|
||||
try writer.print("{d};{d}s", .{
|
||||
self.terminal.scrolling_region.left + 1,
|
||||
self.terminal.scrolling_region.right + 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Our response is valid if we have a response payload
|
||||
const valid = writer.end > prefix_len;
|
||||
|
||||
// Write the terminator
|
||||
try writer.writeAll("\x1b\\");
|
||||
|
||||
// Write the response prefix into the buffer
|
||||
_ = try std.fmt.bufPrint(response[0..prefix_len], prefix_fmt, .{@intFromBool(valid)});
|
||||
const msg = try termio.Message.writeReq(self.alloc, response[0..writer.end]);
|
||||
var response: [terminal.dcs.Command.DECRQSS.max_response_bytes]u8 = undefined;
|
||||
const encoded = try decrqss.encode(self.terminal, &response);
|
||||
const msg = try termio.Message.writeReq(
|
||||
self.alloc,
|
||||
encoded,
|
||||
);
|
||||
self.messageWriter(msg);
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user