b2d1bc2e
Wire terminal effect callbacks
a73x 2026-04-08 13:09
Commit message
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -14,6 +14,19 @@ const GridSize = struct { | |||
| 14 | rows: u16, | 14 | rows: u16, |
| 15 | }; | 15 | }; |
| 16 | 16 | ||
| 17 | fn writePtyFromTerminal(_: *vt.Terminal, ctx: ?*anyopaque, data: []const u8) void { | ||
| 18 | const p: *pty.Pty = @ptrCast(@alignCast(ctx orelse return)); | ||
| 19 | _ = p.write(data) catch |err| { | ||
| 20 | std.log.warn("pty write callback failed: {}", .{err}); | ||
| 21 | return; | ||
| 22 | }; | ||
| 23 | } | ||
| 24 | |||
| 25 | fn updateWindowTitle(_: *vt.Terminal, ctx: ?*anyopaque, title: ?[:0]const u8) void { | ||
| 26 | const window: *wayland_client.Window = @ptrCast(@alignCast(ctx orelse return)); | ||
| 27 | window.setTitle(title); | ||
| 28 | } | ||
| 29 | |||
| 17 | pub fn main() !void { | 30 | pub fn main() !void { |
| 18 | var gpa: std.heap.DebugAllocator(.{}) = .init; | 31 | var gpa: std.heap.DebugAllocator(.{}) = .init; |
| 19 | defer _ = gpa.deinit(); | 32 | defer _ = gpa.deinit(); |
| @@ -106,6 +119,13 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 106 | .max_scrollback = 1000, | 119 | .max_scrollback = 1000, |
| 107 | }); | 120 | }); |
| 108 | defer term.deinit(); | 121 | defer term.deinit(); |
| 122 | term.setTitleChangedCallback(window, &updateWindowTitle); | ||
| 123 | term.setReportedSize(.{ | ||
| 124 | .rows = rows, | ||
| 125 | .columns = cols, | ||
| 126 | .cell_width = cell_w, | ||
| 127 | .cell_height = cell_h, | ||
| 128 | }); | ||
| 109 | 129 | ||
| 110 | // === pty === | 130 | // === pty === |
| 111 | const shell: [:0]const u8 = blk: { | 131 | const shell: [:0]const u8 = blk: { |
| @@ -116,6 +136,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 116 | 136 | ||
| 117 | var p = try pty.Pty.spawn(.{ .cols = cols, .rows = rows, .shell = shell }); | 137 | var p = try pty.Pty.spawn(.{ .cols = cols, .rows = rows, .shell = shell }); |
| 118 | defer p.deinit(); | 138 | defer p.deinit(); |
| 139 | term.setWritePtyCallback(&p, &writePtyFromTerminal); | ||
| 119 | 140 | ||
| 120 | // === instance buffer === | 141 | // === instance buffer === |
| 121 | var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty; | 142 | var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty; |
| @@ -183,6 +204,12 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 183 | try p.resize(new_grid.cols, new_grid.rows); | 204 | try p.resize(new_grid.cols, new_grid.rows); |
| 184 | cols = new_grid.cols; | 205 | cols = new_grid.cols; |
| 185 | rows = new_grid.rows; | 206 | rows = new_grid.rows; |
| 207 | term.setReportedSize(.{ | ||
| 208 | .rows = rows, | ||
| 209 | .columns = cols, | ||
| 210 | .cell_width = cell_w, | ||
| 211 | .cell_height = cell_h, | ||
| 212 | }); | ||
| 186 | } else { | 213 | } else { |
| 187 | _ = try ctx.vkd.deviceWaitIdle(ctx.device); | 214 | _ = try ctx.vkd.deviceWaitIdle(ctx.device); |
| 188 | try ctx.recreateSwapchain(window.width, window.height); | 215 | try ctx.recreateSwapchain(window.width, window.height); |
src/vt.zig
| Old | New | ||
|---|---|---|---|
| @@ -49,20 +49,39 @@ | |||
| 49 | 49 | ||
| 50 | const std = @import("std"); | 50 | const std = @import("std"); |
| 51 | const ghostty_vt = @import("ghostty-vt"); | 51 | const ghostty_vt = @import("ghostty-vt"); |
| 52 | const DeviceAttributesCallback = std.meta.Child( | ||
| 53 | @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), | ||
| 54 | ); | ||
| 55 | const DeviceAttributesReturn = @typeInfo( | ||
| 56 | std.meta.Child(DeviceAttributesCallback), | ||
| 57 | ).@"fn".return_type.?; | ||
| 52 | 58 | ||
| 53 | pub const InputAction = ghostty_vt.input.KeyAction; | 59 | pub const InputAction = ghostty_vt.input.KeyAction; |
| 54 | pub const InputKey = ghostty_vt.input.Key; | 60 | pub const InputKey = ghostty_vt.input.Key; |
| 55 | pub const InputMods = ghostty_vt.input.KeyMods; | 61 | pub const InputMods = ghostty_vt.input.KeyMods; |
| 62 | pub const Size = ghostty_vt.size_report.Size; | ||
| 56 | pub const CellColors = struct { | 63 | pub const CellColors = struct { |
| 57 | fg: [4]f32, | 64 | fg: [4]f32, |
| 58 | bg: [4]f32, | 65 | bg: [4]f32, |
| 59 | }; | 66 | }; |
| 60 | 67 | ||
| 61 | pub const Terminal = struct { | 68 | pub const Terminal = struct { |
| 69 | pub const WritePtyFn = *const fn (*Terminal, ?*anyopaque, []const u8) void; | ||
| 70 | pub const TitleChangedFn = *const fn (*Terminal, ?*anyopaque, ?[:0]const u8) void; | ||
| 71 | |||
| 72 | const Hooks = struct { | ||
| 73 | write_pty_ctx: ?*anyopaque = null, | ||
| 74 | write_pty: ?WritePtyFn = null, | ||
| 75 | title_changed_ctx: ?*anyopaque = null, | ||
| 76 | title_changed: ?TitleChangedFn = null, | ||
| 77 | reported_size: ?Size = null, | ||
| 78 | }; | ||
| 79 | |||
| 62 | alloc: std.mem.Allocator, | 80 | alloc: std.mem.Allocator, |
| 63 | inner: ghostty_vt.Terminal, | 81 | inner: ghostty_vt.Terminal, |
| 64 | stream: ghostty_vt.TerminalStream, | 82 | stream: ghostty_vt.TerminalStream, |
| 65 | render_state: ghostty_vt.RenderState, | 83 | render_state: ghostty_vt.RenderState, |
| 84 | hooks: Hooks = .{}, | ||
| 66 | 85 | ||
| 67 | pub const InitOptions = struct { | 86 | pub const InitOptions = struct { |
| 68 | cols: u16, | 87 | cols: u16, |
| @@ -94,6 +113,7 @@ pub const Terminal = struct { | |||
| 94 | .inner = inner, | 113 | .inner = inner, |
| 95 | .stream = stream, | 114 | .stream = stream, |
| 96 | .render_state = render_state, | 115 | .render_state = render_state, |
| 116 | .hooks = .{}, | ||
| 97 | }; | 117 | }; |
| 98 | } | 118 | } |
| 99 | 119 | ||
| @@ -101,6 +121,12 @@ pub const Terminal = struct { | |||
| 101 | /// to its final memory location. Must be called once before write(). | 121 | /// to its final memory location. Must be called once before write(). |
| 102 | fn fixupStreamPointer(self: *Terminal) void { | 122 | fn fixupStreamPointer(self: *Terminal) void { |
| 103 | self.stream.handler.terminal = &self.inner; | 123 | self.stream.handler.terminal = &self.inner; |
| 124 | self.stream.handler.effects.write_pty = &streamWritePty; | ||
| 125 | self.stream.handler.effects.title_changed = &streamTitleChanged; | ||
| 126 | self.stream.handler.effects.size = &streamSize; | ||
| 127 | self.stream.handler.effects.device_attributes = &streamDeviceAttributes; | ||
| 128 | self.stream.handler.effects.xtversion = &streamXtversion; | ||
| 129 | self.stream.handler.effects.color_scheme = &streamColorScheme; | ||
| 104 | } | 130 | } |
| 105 | 131 | ||
| 106 | pub fn deinit(self: *Terminal) void { | 132 | pub fn deinit(self: *Terminal) void { |
| @@ -109,10 +135,35 @@ pub const Terminal = struct { | |||
| 109 | } | 135 | } |
| 110 | 136 | ||
| 111 | pub fn write(self: *Terminal, bytes: []const u8) void { | 137 | pub fn write(self: *Terminal, bytes: []const u8) void { |
| 112 | self.stream.handler.terminal = &self.inner; | 138 | self.fixupStreamPointer(); |
| 113 | self.stream.nextSlice(bytes); | 139 | self.stream.nextSlice(bytes); |
| 114 | } | 140 | } |
| 115 | 141 | ||
| 142 | pub fn setWritePtyCallback( | ||
| 143 | self: *Terminal, | ||
| 144 | ctx: ?*anyopaque, | ||
| 145 | callback: ?WritePtyFn, | ||
| 146 | ) void { | ||
| 147 | self.hooks.write_pty_ctx = ctx; | ||
| 148 | self.hooks.write_pty = callback; | ||
| 149 | self.fixupStreamPointer(); | ||
| 150 | } | ||
| 151 | |||
| 152 | pub fn setTitleChangedCallback( | ||
| 153 | self: *Terminal, | ||
| 154 | ctx: ?*anyopaque, | ||
| 155 | callback: ?TitleChangedFn, | ||
| 156 | ) void { | ||
| 157 | self.hooks.title_changed_ctx = ctx; | ||
| 158 | self.hooks.title_changed = callback; | ||
| 159 | self.fixupStreamPointer(); | ||
| 160 | } | ||
| 161 | |||
| 162 | pub fn setReportedSize(self: *Terminal, size: Size) void { | ||
| 163 | self.hooks.reported_size = size; | ||
| 164 | self.fixupStreamPointer(); | ||
| 165 | } | ||
| 166 | |||
| 116 | pub fn encodeKey( | 167 | pub fn encodeKey( |
| 117 | self: *const Terminal, | 168 | self: *const Terminal, |
| 118 | key: InputKey, | 169 | key: InputKey, |
| @@ -182,6 +233,40 @@ fn rgbToFloat4(rgb: ghostty_vt.color.RGB) [4]f32 { | |||
| 182 | }; | 233 | }; |
| 183 | } | 234 | } |
| 184 | 235 | ||
| 236 | fn terminalFromHandler(handler: *ghostty_vt.TerminalStream.Handler) *Terminal { | ||
| 237 | const inner: *ghostty_vt.Terminal = handler.terminal; | ||
| 238 | return @fieldParentPtr("inner", inner); | ||
| 239 | } | ||
| 240 | |||
| 241 | fn streamWritePty(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { | ||
| 242 | const self = terminalFromHandler(handler); | ||
| 243 | const callback = self.hooks.write_pty orelse return; | ||
| 244 | callback(self, self.hooks.write_pty_ctx, data); | ||
| 245 | } | ||
| 246 | |||
| 247 | fn streamTitleChanged(handler: *ghostty_vt.TerminalStream.Handler) void { | ||
| 248 | const self = terminalFromHandler(handler); | ||
| 249 | const callback = self.hooks.title_changed orelse return; | ||
| 250 | callback(self, self.hooks.title_changed_ctx, self.inner.getTitle()); | ||
| 251 | } | ||
| 252 | |||
| 253 | fn streamSize(handler: *ghostty_vt.TerminalStream.Handler) ?Size { | ||
| 254 | const self = terminalFromHandler(handler); | ||
| 255 | return self.hooks.reported_size; | ||
| 256 | } | ||
| 257 | |||
| 258 | fn streamDeviceAttributes(_: *ghostty_vt.TerminalStream.Handler) DeviceAttributesReturn { | ||
| 259 | return .{}; | ||
| 260 | } | ||
| 261 | |||
| 262 | fn streamXtversion(_: *ghostty_vt.TerminalStream.Handler) []const u8 { | ||
| 263 | return "waystty"; | ||
| 264 | } | ||
| 265 | |||
| 266 | fn streamColorScheme(_: *ghostty_vt.TerminalStream.Handler) ?ghostty_vt.device_status.ColorScheme { | ||
| 267 | return null; | ||
| 268 | } | ||
| 269 | |||
| 185 | test "Terminal init/deinit" { | 270 | test "Terminal init/deinit" { |
| 186 | var term = try Terminal.init(std.testing.allocator, .{ | 271 | var term = try Terminal.init(std.testing.allocator, .{ |
| 187 | .cols = 80, | 272 | .cols = 80, |
| @@ -246,3 +331,153 @@ test "Terminal resolves ANSI fg and bg colors from render state" { | |||
| 246 | try std.testing.expectEqualDeep(rgbToFloat4(palette[1]), colors.fg); | 331 | try std.testing.expectEqualDeep(rgbToFloat4(palette[1]), colors.fg); |
| 247 | try std.testing.expectEqualDeep(rgbToFloat4(palette[4]), colors.bg); | 332 | try std.testing.expectEqualDeep(rgbToFloat4(palette[4]), colors.bg); |
| 248 | } | 333 | } |
| 334 | |||
| 335 | test "Terminal title callback fires on OSC 2" { | ||
| 336 | var term = try Terminal.init(std.testing.allocator, .{ | ||
| 337 | .cols = 80, | ||
| 338 | .rows = 24, | ||
| 339 | }); | ||
| 340 | defer term.deinit(); | ||
| 341 | |||
| 342 | const S = struct { | ||
| 343 | var title: ?[]const u8 = null; | ||
| 344 | |||
| 345 | fn onTitle(_: *Terminal, _: ?*anyopaque, value: ?[:0]const u8) void { | ||
| 346 | title = if (value) |v| v else null; | ||
| 347 | } | ||
| 348 | }; | ||
| 349 | S.title = null; | ||
| 350 | |||
| 351 | term.setTitleChangedCallback(null, &S.onTitle); | ||
| 352 | term.write("\x1b]2;waystty\x1b\\"); | ||
| 353 | |||
| 354 | try std.testing.expectEqualStrings("waystty", S.title.?); | ||
| 355 | } | ||
| 356 | |||
| 357 | test "Terminal title callback fires on empty OSC 2" { | ||
| 358 | var term = try Terminal.init(std.testing.allocator, .{ | ||
| 359 | .cols = 80, | ||
| 360 | .rows = 24, | ||
| 361 | }); | ||
| 362 | defer term.deinit(); | ||
| 363 | |||
| 364 | const S = struct { | ||
| 365 | var saw_callback = false; | ||
| 366 | var title_is_null = false; | ||
| 367 | |||
| 368 | fn onTitle(_: *Terminal, _: ?*anyopaque, value: ?[:0]const u8) void { | ||
| 369 | saw_callback = true; | ||
| 370 | title_is_null = value == null; | ||
| 371 | } | ||
| 372 | }; | ||
| 373 | S.saw_callback = false; | ||
| 374 | S.title_is_null = false; | ||
| 375 | |||
| 376 | term.setTitleChangedCallback(null, &S.onTitle); | ||
| 377 | term.write("\x1b]2;\x1b\\"); | ||
| 378 | |||
| 379 | try std.testing.expect(S.saw_callback); | ||
| 380 | try std.testing.expect(S.title_is_null); | ||
| 381 | } | ||
| 382 | |||
| 383 | test "Terminal write_pty callback emits cursor position reports" { | ||
| 384 | var term = try Terminal.init(std.testing.allocator, .{ | ||
| 385 | .cols = 80, | ||
| 386 | .rows = 24, | ||
| 387 | }); | ||
| 388 | defer term.deinit(); | ||
| 389 | |||
| 390 | const S = struct { | ||
| 391 | var written: [128]u8 = undefined; | ||
| 392 | var written_len: usize = 0; | ||
| 393 | |||
| 394 | fn onWrite(_: *Terminal, _: ?*anyopaque, data: []const u8) void { | ||
| 395 | @memcpy(written[0..data.len], data); | ||
| 396 | written_len = data.len; | ||
| 397 | } | ||
| 398 | }; | ||
| 399 | S.written_len = 0; | ||
| 400 | |||
| 401 | term.setWritePtyCallback(null, &S.onWrite); | ||
| 402 | term.write("\x1b[6n"); | ||
| 403 | |||
| 404 | try std.testing.expectEqualStrings("\x1b[1;1R", S.written[0..S.written_len]); | ||
| 405 | } | ||
| 406 | |||
| 407 | test "Terminal reports waystty for XTVERSION queries" { | ||
| 408 | var term = try Terminal.init(std.testing.allocator, .{ | ||
| 409 | .cols = 80, | ||
| 410 | .rows = 24, | ||
| 411 | }); | ||
| 412 | defer term.deinit(); | ||
| 413 | |||
| 414 | const S = struct { | ||
| 415 | var written: [128]u8 = undefined; | ||
| 416 | var written_len: usize = 0; | ||
| 417 | |||
| 418 | fn onWrite(_: *Terminal, _: ?*anyopaque, data: []const u8) void { | ||
| 419 | @memcpy(written[0..data.len], data); | ||
| 420 | written_len = data.len; | ||
| 421 | } | ||
| 422 | }; | ||
| 423 | S.written_len = 0; | ||
| 424 | |||
| 425 | term.setWritePtyCallback(null, &S.onWrite); | ||
| 426 | term.write("\x1b[>0q"); | ||
| 427 | |||
| 428 | try std.testing.expectEqualStrings("\x1bP>|waystty\x1b\\", S.written[0..S.written_len]); | ||
| 429 | } | ||
| 430 | |||
| 431 | test "Terminal reports configured size queries" { | ||
| 432 | var term = try Terminal.init(std.testing.allocator, .{ | ||
| 433 | .cols = 80, | ||
| 434 | .rows = 24, | ||
| 435 | }); | ||
| 436 | defer term.deinit(); | ||
| 437 | |||
| 438 | const S = struct { | ||
| 439 | var written: [128]u8 = undefined; | ||
| 440 | var written_len: usize = 0; | ||
| 441 | |||
| 442 | fn onWrite(_: *Terminal, _: ?*anyopaque, data: []const u8) void { | ||
| 443 | @memcpy(written[0..data.len], data); | ||
| 444 | written_len = data.len; | ||
| 445 | } | ||
| 446 | }; | ||
| 447 | S.written_len = 0; | ||
| 448 | |||
| 449 | term.setWritePtyCallback(null, &S.onWrite); | ||
| 450 | term.setReportedSize(.{ | ||
| 451 | .rows = 24, | ||
| 452 | .columns = 80, | ||
| 453 | .cell_width = 9, | ||
| 454 | .cell_height = 18, | ||
| 455 | }); | ||
| 456 | term.write("\x1b[18t"); | ||
| 457 | |||
| 458 | try std.testing.expectEqualStrings("\x1b[8;24;80t", S.written[0..S.written_len]); | ||
| 459 | } | ||
| 460 | |||
| 461 | test "Terminal reports default device attributes queries" { | ||
| 462 | var term = try Terminal.init(std.testing.allocator, .{ | ||
| 463 | .cols = 80, | ||
| 464 | .rows = 24, | ||
| 465 | }); | ||
| 466 | defer term.deinit(); | ||
| 467 | |||
| 468 | const S = struct { | ||
| 469 | var buf: [64]u8 = undefined; | ||
| 470 | var written_len: usize = 0; | ||
| 471 | |||
| 472 | fn onWrite(_: *Terminal, _: ?*anyopaque, data: []const u8) void { | ||
| 473 | written_len = data.len; | ||
| 474 | @memcpy(buf[0..data.len], data); | ||
| 475 | } | ||
| 476 | }; | ||
| 477 | S.written_len = 0; | ||
| 478 | |||
| 479 | term.setWritePtyCallback(null, &S.onWrite); | ||
| 480 | term.write("\x1b[c"); | ||
| 481 | |||
| 482 | try std.testing.expectEqualStrings("\x1b[?62;22c", S.buf[0..S.written_len]); | ||
| 483 | } | ||
src/wayland.zig
| Old | New | ||
|---|---|---|---|
| @@ -127,6 +127,10 @@ pub const Window = struct { | |||
| 127 | self.surface.destroy(); | 127 | self.surface.destroy(); |
| 128 | self.alloc.destroy(self); | 128 | self.alloc.destroy(self); |
| 129 | } | 129 | } |
| 130 | |||
| 131 | pub fn setTitle(self: *Window, title: ?[:0]const u8) void { | ||
| 132 | self.xdg_toplevel.setTitle((title orelse "waystty")); | ||
| 133 | } | ||
| 130 | }; | 134 | }; |
| 131 | 135 | ||
| 132 | pub const Connection = struct { | 136 | pub const Connection = struct { |