diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
| commit | 2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch) | |
| tree | 7cd6274303ba1da1f8bd57371aec336dd687fb58 /vendor/libghostty-vt/src/terminal/dcs.zig | |
| download | slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.gz slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.xz slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.zip | |
sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer
Design contract in DESIGN.md. libghostty-vt vendored fresh from ghostty
main@bedb6f2 (zig 0.16), built to a 14MB static lib.
M0: pty spawn (no libutil), a lib-vt terminal per pane, a screen cell buffer,
a diff emitter that repaints only what changed, raw mode, SIGWINCH, and a
~120Hz frame cap that coalesces pty reads.
Also the seed of the headless harness: --headless dumps the composited screen
without a tty, so composition is testable in milliseconds.
tests/live_m0.py covers what headless cannot: raw mode, the diff emitter,
resize propagation, and the quit path. 6/6 green.
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/dcs.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/dcs.zig | 571 |
1 files changed, 571 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/dcs.zig b/vendor/libghostty-vt/src/terminal/dcs.zig new file mode 100644 index 0000000..a76ab31 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/dcs.zig @@ -0,0 +1,571 @@ +const std = @import("std"); +const build_options = @import("terminal_options"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const terminal = @import("main.zig"); +const DCS = terminal.DCS; + +const log = std.log.scoped(.terminal_dcs); + +/// DCS command handler. This should be hooked into a terminal.Stream handler. +/// The hook/put/unhook functions are meant to be called from the +/// terminal.stream dcsHook, dcsPut, and dcsUnhook functions, respectively. +pub const Handler = struct { + state: State = .{ .inactive = {} }, + + /// Maximum bytes any DCS command can take. This is to prevent + /// malicious input from causing us to allocate too much memory. + /// This is arbitrarily set to 1MB today, increase if needed. + max_bytes: usize = 1024 * 1024, + + pub fn deinit(self: *Handler) void { + self.discard(); + } + + pub fn hook(self: *Handler, alloc: Allocator, dcs: DCS) ?Command { + assert(self.state == .inactive); + + // Initialize our state to ignore in case of error + self.state = .ignore; + + // Try to parse the hook. + const hk_ = self.tryHook(alloc, dcs) catch |err| { + log.info("error initializing DCS hook, will ignore hook err={}", .{err}); + return null; + }; + const hk = hk_ orelse { + log.info("unknown DCS hook: {}", .{dcs}); + return null; + }; + + self.state = hk.state; + return hk.command; + } + + const Hook = struct { + state: State, + command: ?Command = null, + }; + + fn tryHook(self: Handler, alloc: Allocator, dcs: DCS) !?Hook { + return switch (dcs.intermediates.len) { + 0 => switch (dcs.final) { + // Tmux control mode + 'p' => tmux: { + if (comptime !build_options.tmux_control_mode) { + log.debug("tmux control mode not enabled in build, ignoring", .{}); + break :tmux null; + } + + // Tmux control mode must start with ESC P 1000 p + if (dcs.params.len != 1 or dcs.params[0] != 1000) break :tmux null; + + break :tmux .{ + .state = .{ + .tmux = .{ + .max_bytes = self.max_bytes, + .buffer = try .initCapacity( + alloc, + 128, // Arbitrary choice to limit initial reallocs + ), + }, + }, + .command = .{ .tmux = .enter }, + }; + }, + + else => null, + }, + + 1 => switch (dcs.intermediates[0]) { + '+' => switch (dcs.final) { + // XTGETTCAP + // https://github.com/mitchellh/ghostty/issues/517 + 'q' => .{ + .state = .{ + .xtgettcap = try .initCapacity( + alloc, + 128, // Arbitrary choice + ), + }, + }, + + else => null, + }, + + '$' => switch (dcs.final) { + // DECRQSS + 'q' => .{ .state = .{ + .decrqss = .{}, + } }, + + else => null, + }, + + else => null, + }, + + else => null, + }; + } + + /// Put a byte into the DCS handler. This will return a command + /// if a command needs to be executed. + pub fn put(self: *Handler, byte: u8) ?Command { + return self.tryPut(byte) catch |err| { + // On error we just discard our state and ignore the rest + log.info("error putting byte into DCS handler err={}", .{err}); + self.discard(); + self.state = .ignore; + return null; + }; + } + + fn tryPut(self: *Handler, byte: u8) !?Command { + switch (self.state) { + .inactive, + .ignore, + => {}, + + .tmux => |*tmux| if (comptime build_options.tmux_control_mode) { + return .{ + .tmux = (try tmux.put(byte)) orelse return null, + }; + } else unreachable, + + .xtgettcap => |*list| { + if (list.written().len >= self.max_bytes) { + return error.OutOfMemory; + } + + try list.writer.writeByte(byte); + }, + + .decrqss => |*buffer| { + if (buffer.len >= buffer.data.len) { + return error.OutOfMemory; + } + + buffer.data[buffer.len] = byte; + buffer.len += 1; + }, + } + + return null; + } + + pub fn unhook(self: *Handler) ?Command { + // Note: we do NOT call deinit here on purpose because some commands + // transfer memory ownership. If state needs cleanup, the switch + // prong below should handle it. + defer self.state = .inactive; + + return switch (self.state) { + .inactive, + .ignore, + => null, + + .tmux => if (comptime build_options.tmux_control_mode) tmux: { + self.state.deinit(); + break :tmux .{ .tmux = .exit }; + } else unreachable, + + .xtgettcap => |*list| xtgettcap: { + // Note: purposely do not deinit our state here because + // we copy it into the resulting command. + const items = list.written(); + for (items, 0..) |b, i| items[i] = std.ascii.toUpper(b); + break :xtgettcap .{ .xtgettcap = .{ .data = list.* } }; + }, + + .decrqss => |buffer| .{ .decrqss = switch (buffer.len) { + 0 => .none, + 1 => switch (buffer.data[0]) { + 'm' => .sgr, + 'r' => .decstbm, + 's' => .decslrm, + else => .none, + }, + 2 => switch (buffer.data[0]) { + ' ' => switch (buffer.data[1]) { + 'q' => .decscusr, + else => .none, + }, + else => .none, + }, + else => unreachable, + } }, + }; + } + + fn discard(self: *Handler) void { + self.state.deinit(); + self.state = .inactive; + } +}; + +pub const Command = union(enum) { + /// XTGETTCAP + xtgettcap: XTGETTCAP, + + /// DECRQSS + decrqss: DECRQSS, + + /// Tmux control mode + tmux: if (build_options.tmux_control_mode) + terminal.tmux.ControlNotification + else + void, + + pub fn deinit(self: *Command) void { + switch (self.*) { + .xtgettcap => |*v| v.data.deinit(), + .decrqss => {}, + .tmux => {}, + } + } + + pub const XTGETTCAP = struct { + data: std.Io.Writer.Allocating, + i: usize = 0, + + /// Returns the next terminfo key being requested and null + /// when there are no more keys. The returned value is NOT hex-decoded + /// because we expect to use a comptime lookup table. + pub fn next(self: *XTGETTCAP) ?[]const u8 { + const items = self.data.written(); + if (self.i >= items.len) return null; + var rem = items[self.i..]; + const idx = std.mem.indexOf(u8, rem, ";") orelse rem.len; + + // Note that if we're at the end, idx + 1 is len + 1 so we're over + // the end but that's okay because our check above is >= so we'll + // never read. + self.i += idx + 1; + + return rem[0..idx]; + } + }; + + /// Supported DECRQSS settings + pub const DECRQSS = enum { + none, + sgr, + 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(); + } + }; +}; + +const State = union(enum) { + /// We're not in a DCS state at the moment. + inactive, + + /// We're hooked, but its an unknown DCS command or one that went + /// invalid due to some bad input, so we're ignoring the rest. + ignore, + + /// XTGETTCAP + xtgettcap: std.Io.Writer.Allocating, + + /// DECRQSS + decrqss: struct { + data: [2]u8 = undefined, + len: u2 = 0, + }, + + /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode + tmux: if (build_options.tmux_control_mode) + terminal.tmux.ControlParser + else + void, + + pub fn deinit(self: *State) void { + switch (self.*) { + .inactive, + .ignore, + => {}, + + .xtgettcap => |*v| v.deinit(), + .decrqss => {}, + .tmux => |*v| if (comptime build_options.tmux_control_mode) { + v.deinit(); + } else unreachable, + } + } +}; + +test "unknown DCS command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + try testing.expect(h.hook(alloc, .{ .final = 'A' }) == null); + try testing.expect(h.state == .ignore); + try testing.expect(h.unhook() == null); + try testing.expect(h.state == .inactive); +} + +test "XTGETTCAP command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null); + for ("536D756C78") |byte| _ = h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expect(cmd.xtgettcap.next() == null); +} + +test "XTGETTCAP mixed case" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null); + for ("536d756C78") |byte| _ = h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expect(cmd.xtgettcap.next() == null); +} + +test "XTGETTCAP command multiple keys" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null); + for ("536D756C78;536D756C78") |byte| _ = h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expect(cmd.xtgettcap.next() == null); +} + +test "XTGETTCAP command invalid data" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null); + for ("who;536D756C78") |byte| _ = h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("WHO", cmd.xtgettcap.next().?); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expect(cmd.xtgettcap.next() == null); +} + +test "DECRQSS command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null); + _ = h.put('m'); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .decrqss); + try testing.expect(cmd.decrqss == .sgr); +} + +test "DECRQSS invalid command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null); + _ = h.put('z'); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .decrqss); + try testing.expect(cmd.decrqss == .none); + + h.discard(); + + try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null); + _ = h.put('"'); + _ = h.put(' '); + _ = h.put('q'); + 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(.overline); + 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;53;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; + + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + + { + var cmd = h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?; + defer cmd.deinit(); + try testing.expect(cmd == .tmux); + try testing.expect(cmd.tmux == .enter); + } + + { + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .tmux); + try testing.expect(cmd.tmux == .exit); + } +} |
