diff --git a/include/ghostty.h b/include/ghostty.h index 2feb35ad9..0db439e24 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -408,6 +408,7 @@ typedef void (*ghostty_runtime_wakeup_cb)(void*); typedef const ghostty_config_t (*ghostty_runtime_reload_config_cb)(void*); typedef void (*ghostty_runtime_open_config_cb)(void*); typedef void (*ghostty_runtime_set_title_cb)(void*, const char*); +typedef const char* (*ghostty_runtime_get_title_cb)(void*); typedef void (*ghostty_runtime_set_mouse_shape_cb)(void*, ghostty_mouse_shape_e); typedef void (*ghostty_runtime_set_mouse_visibility_cb)(void*, bool); @@ -462,6 +463,7 @@ typedef struct { ghostty_runtime_reload_config_cb reload_config_cb; ghostty_runtime_open_config_cb open_config_cb; ghostty_runtime_set_title_cb set_title_cb; + ghostty_runtime_get_title_cb get_title_cb; ghostty_runtime_set_mouse_shape_cb set_mouse_shape_cb; ghostty_runtime_set_mouse_visibility_cb set_mouse_visibility_cb; ghostty_runtime_read_clipboard_cb read_clipboard_cb; diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index b4fe17f86..d9470c548 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -70,6 +70,7 @@ extension Ghostty { reload_config_cb: { userdata in App.reloadConfig(userdata) }, open_config_cb: { userdata in App.openConfig(userdata) }, set_title_cb: { userdata, title in App.setTitle(userdata, title: title) }, + get_title_cb: { userdata in App.title(userdata) }, set_mouse_shape_cb: { userdata, shape in App.setMouseShape(userdata, shape: shape) }, set_mouse_visibility_cb: { userdata, visible in App.setMouseVisibility(userdata, visible: visible) }, read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) }, diff --git a/src/Surface.zig b/src/Surface.zig index 8cf8ea1fe..69b185de0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -713,6 +713,26 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { try self.rt_surface.setTitle(slice); }, + .report_title => |style| { + const title: ?[:0]const u8 = if (@hasDecl(apprt.runtime.Surface, "getTitle")) + self.rt_surface.getTitle() + else + // If the apprt does not implement getTitle, report a + // blank title. + ""; + + const data = switch (style) { + .csi_21_t => try std.fmt.allocPrint(self.alloc, "\x1b]l{s}\x1b\\", .{title orelse ""}), + }; + + self.io.queueMessage(.{ + .write_alloc = .{ + .alloc = self.alloc, + .data = data, + }, + }, .unlocked); + }, + .set_mouse_shape => |shape| { log.debug("changing mouse shape: {}", .{shape}); try self.rt_surface.setMouseShape(shape); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 82392402b..f4a9a29e2 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -55,6 +55,9 @@ pub const App = struct { /// Called to set the title of the window. set_title: *const fn (SurfaceUD, [*]const u8) callconv(.C) void, + /// Called to get the title of the window. + get_title: ?*const fn (SurfaceUD) callconv(.C) ?[*]const u8 = null, + /// Called to set the cursor shape. set_mouse_shape: *const fn (SurfaceUD, terminal.MouseShape) callconv(.C) void, @@ -543,6 +546,17 @@ pub const Surface = struct { ); } + pub fn getTitle(self: *Surface) ?[:0]const u8 { + const func = self.app.opts.get_title orelse { + log.info("runtime embedder does not support get_title", .{}); + return null; + }; + + const result = func(self.userdata); + if (result == null) return null; + return std.mem.sliceTo(@as([*:0]const u8, @ptrCast(result.?)), 0); + } + pub fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void { self.app.opts.set_mouse_shape( self.userdata, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 81063bc69..89448c354 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -360,6 +360,11 @@ pub const Surface = struct { /// The monitor dimensions so we can toggle fullscreen on and off. monitor_dims: MonitorDimensions, + /// Save the title text so that we can return it later when requested. + /// This is allocated from the heap so it must be freed when we deinit the + /// surface. + title_text: ?[:0]const u8 = null, + pub const Options = struct {}; /// Initialize the surface into the given self pointer. This gives a @@ -463,6 +468,8 @@ pub const Surface = struct { } pub fn deinit(self: *Surface) void { + if (self.title_text) |t| self.core_surface.alloc.free(t); + // Remove ourselves from the list of known surfaces in the app. self.app.app.deleteSurface(self); @@ -609,7 +616,14 @@ pub const Surface = struct { /// Set the title of the window. pub fn setTitle(self: *Surface, slice: [:0]const u8) !void { - self.window.setTitle(slice.ptr); + if (self.title_text) |t| self.core_surface.alloc.free(t); + self.title_text = try self.core_surface.alloc.dupeZ(u8, slice); + self.window.setTitle(self.title_text.?.ptr); + } + + /// Return the title of the window. + pub fn getTitle(self: *Surface) ?[:0]const u8 { + return self.title_text; } /// Set the shape of the cursor. diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 2a7c7d183..a0170b2ea 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -935,6 +935,10 @@ pub fn setTitle(self: *Surface, slice: [:0]const u8) !void { self.updateTitleLabels(); } +pub fn getTitle(self: *Surface) ?[:0]const u8 { + return self.title_text; +} + pub fn setMouseShape( self: *Surface, shape: terminal.MouseShape, diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index ae3ba050a..aefe6fb97 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -18,6 +18,9 @@ pub const Message = union(enum) { /// of any length set_title: [256]u8, + /// Report the window title back to the terminal + report_title: ReportTitleStyle, + /// Set the mouse shape. set_mouse_shape: terminal.MouseShape, @@ -57,6 +60,10 @@ pub const Message = union(enum) { /// Report the color scheme report_color_scheme: void, + + pub const ReportTitleStyle = enum { + csi_21_t, + }; }; /// A surface mailbox. diff --git a/src/terminal/main.zig b/src/terminal/main.zig index b486a8da5..8d31e6916 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -3,7 +3,7 @@ const builtin = @import("builtin"); pub usingnamespace @import("sanitize.zig"); const charsets = @import("charsets.zig"); -const stream = @import("stream.zig"); +pub const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); const hyperlink = @import("hyperlink.zig"); diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index a9c802965..bfb6a4206 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -24,6 +24,13 @@ const log = std.log.scoped(.stream); /// do something else. const debug = false; +pub const ReportStyle = enum { + csi_14_t, + csi_16_t, + csi_18_t, + csi_21_t, +}; + /// Returns a type that can process a stream of tty control characters. /// This will call various callback functions on type T. Type T only has to /// implement the callbacks it cares about; any unimplemented callbacks will @@ -1109,6 +1116,75 @@ pub fn Stream(comptime Handler: type) type { ), }, + // XTWINOPS + 't' => switch (input.intermediates.len) { + 0 => { + if (input.params.len > 0) { + switch (input.params[0]) { + 14 => if (input.params.len == 1) { + // report the text area size in pixels + if (@hasDecl(T, "sendReport")) { + self.handler.sendReport(.csi_14_t); + } else log.warn( + "ignoring unimplemented CSI 14 t", + .{}, + ); + } else log.warn( + "ignoring CSI 14 t with extra parameters: {}", + .{input}, + ), + 16 => if (input.params.len == 1) { + // report cell size in pixels + if (@hasDecl(T, "sendReport")) { + self.handler.sendReport(.csi_16_t); + } else log.warn( + "ignoring unimplemented CSI 16 t", + .{}, + ); + } else log.warn( + "ignoring CSI 16 t with extra parameters: {s}", + .{input}, + ), + 18 => if (input.params.len == 1) { + // report screen size in characters + if (@hasDecl(T, "sendReport")) { + self.handler.sendReport(.csi_18_t); + } else log.warn( + "ignoring unimplemented CSI 18 t", + .{}, + ); + } else log.warn( + "ignoring CSI 18 t with extra parameters: {s}", + .{input}, + ), + 21 => if (input.params.len == 1) { + // report window title + if (@hasDecl(T, "sendReport")) { + self.handler.sendReport(.csi_21_t); + } else log.warn( + "ignoring unimplemented CSI 21 t", + .{}, + ); + } else log.warn( + "ignoring CSI 21 t with extra parameters: {s}", + .{input}, + ), + else => log.warn( + "ignoring CSI t with unimplemented parameter: {s}", + .{input}, + ), + } + } else log.err( + "ignoring CSI t with no parameters: {s}", + .{input}, + ); + }, + else => log.warn( + "ignoring unimplemented CSI t with intermediates: {s}", + .{input}, + ), + }, + 'u' => switch (input.intermediates.len) { 0 => if (@hasDecl(T, "restoreCursor")) try self.handler.restoreCursor() @@ -2047,3 +2123,42 @@ test "stream: csi param too long" { var s: Stream(H) = .{ .handler = .{} }; try s.nextSlice("\x1B[1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111C"); } + +test "stream: send report with CSI t" { + const H = struct { + style: ?ReportStyle = null, + + pub fn sendReport(self: *@This(), style: ReportStyle) void { + self.style = style; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + + try s.nextSlice("\x1b[14t"); + try testing.expectEqual(ReportStyle.csi_14_t, s.handler.style); + + try s.nextSlice("\x1b[16t"); + try testing.expectEqual(ReportStyle.csi_16_t, s.handler.style); + + try s.nextSlice("\x1b[18t"); + try testing.expectEqual(ReportStyle.csi_18_t, s.handler.style); + + try s.nextSlice("\x1b[21t"); + try testing.expectEqual(ReportStyle.csi_21_t, s.handler.style); +} + +test "stream: invalid CSI t" { + const H = struct { + style: ?ReportStyle = null, + + pub fn sendReport(self: *@This(), style: ReportStyle) void { + self.style = style; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + + try s.nextSlice("\x1b[19t"); + try testing.expectEqual(null, s.handler.style); +} diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 62373fb76..246b9b2f8 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -383,32 +383,58 @@ pub fn resize( // If we have size reporting enabled we need to send a report. if (self.terminal.modes.get(.in_band_size_reports)) { - try self.sizeReportLocked(td); + try self.sizeReportLocked(td, .mode_2048); } } } -/// Make a mode 2048 in-band size report. -pub fn sizeReport(self: *Termio, td: *ThreadData) !void { +/// Make a size report. +pub fn sizeReport(self: *Termio, td: *ThreadData, style: termio.Message.SizeReport) !void { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - try self.sizeReportLocked(td); + try self.sizeReportLocked(td, style); } -fn sizeReportLocked(self: *Termio, td: *ThreadData) !void { +fn sizeReportLocked(self: *Termio, td: *ThreadData, style: termio.Message.SizeReport) !void { // 1024 bytes should be enough for size report since report // in columns and pixels. var buf: [1024]u8 = undefined; - const message = try std.fmt.bufPrint( - &buf, - "\x1B[48;{};{};{};{}t", - .{ - self.grid_size.rows, - self.grid_size.columns, - self.terminal.height_px, - self.terminal.width_px, - }, - ); + const message = switch (style) { + .mode_2048 => try std.fmt.bufPrint( + &buf, + "\x1B[48;{};{};{};{}t", + .{ + self.grid_size.rows, + self.grid_size.columns, + self.terminal.height_px, + self.terminal.width_px, + }, + ), + .csi_14_t => try std.fmt.bufPrint( + &buf, + "\x1b[4;{};{}t", + .{ + self.terminal.height_px, + self.terminal.width_px, + }, + ), + .csi_16_t => try std.fmt.bufPrint( + &buf, + "\x1b[6;{};{}t", + .{ + self.terminal.height_px / self.grid_size.rows, + self.terminal.width_px / self.grid_size.columns, + }, + ), + .csi_18_t => try std.fmt.bufPrint( + &buf, + "\x1b[8;{};{}t", + .{ + self.grid_size.rows, + self.grid_size.columns, + }, + ), + }; try self.queueWrite(td, message, false); } diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 1dc240827..94650aef6 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -267,7 +267,7 @@ fn drainMailbox( }, .inspector => |v| self.flags.has_inspector = v, .resize => |v| self.handleResize(cb, v), - .size_report => try io.sizeReport(data), + .size_report => |v| try io.sizeReport(data, v), .clear_screen => |v| try io.clearScreen(data, v.history), .scroll_viewport => |v| try io.scrollViewport(v), .jump_to_prompt => |v| try io.jumpToPrompt(v), diff --git a/src/termio/message.zig b/src/termio/message.zig index 5d17ad8d8..792e81cdf 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -42,9 +42,10 @@ pub const Message = union(enum) { /// Resize the window. resize: Resize, - /// Request a size report is sent to the pty (in-band size report, - /// mode 2048: https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83) - size_report: void, + /// Request a size report is sent to the pty ([in-band + /// size report, mode 2048](https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83) and + /// [XTWINOPS](https://invisible-island.net/xterm/ctlseqs/ctlseqs.htmli#h4-Functions-using-CSI-_-ordered-by-the-final-character-lparen-s-rparen:CSI-Ps;Ps;Ps-t.1EB0)). + size_report: SizeReport, /// Clear the screen. clear_screen: struct { @@ -94,6 +95,14 @@ pub const Message = union(enum) { .alloc => |v| Message{ .write_alloc = v }, }; } + + /// The types of size reports that we support + pub const SizeReport = enum { + mode_2048, + csi_14_t, + csi_16_t, + csi_18_t, + }; }; /// Creates a union that can be used to accommodate data that fit within an array, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 51326c03c..48f4858f2 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -598,7 +598,7 @@ pub const StreamHandler = struct { }, .in_band_size_reports => if (enabled) self.messageWriter(.{ - .size_report = {}, + .size_report = .mode_2048, }), .mouse_event_x10 => { @@ -1259,4 +1259,14 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(message); } + + /// Send a report to the pty. + pub fn sendReport(self: *StreamHandler, style: terminal.stream.ReportStyle) void { + switch (style) { + .csi_14_t => self.messageWriter(.{ .size_report = .csi_14_t }), + .csi_16_t => self.messageWriter(.{ .size_report = .csi_16_t }), + .csi_18_t => self.messageWriter(.{ .size_report = .csi_18_t }), + .csi_21_t => self.surfaceMessageWriter(.{ .report_title = .csi_21_t }), + } + } };