diff --git a/src/Surface.zig b/src/Surface.zig index 8cf8ea1fe..6aa39142f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -713,6 +713,31 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { try self.rt_surface.setTitle(slice); }, + .report_title => |style| { + const title: ?[:0]const u8 = title: { + if (!@hasDecl(apprt.runtime.Surface, "getTitle")) break :title null; + break :title self.rt_surface.getTitle(); + }; + + const data = switch (style) { + .csi_21_t => try std.fmt.allocPrint( + self.alloc, + "\x1b]l{s}\x1b\\", + .{title orelse ""}, + ), + }; + + // We always use an allocating message because we don't know + // the length of the title and this isn't a performance critical + // path. + 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..b7c8b2bed 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -306,6 +306,10 @@ pub const Surface = struct { keymap_state: input.Keymap.State, inspector: ?*Inspector = null, + /// The current title of the surface. The embedded apprt saves this so + /// that getTitle works without the implementer needing to save it. + title: ?[:0]const u8 = null, + /// Surface initialization options. pub const Options = extern struct { /// The platform that this surface is being initialized for and @@ -429,6 +433,9 @@ pub const Surface = struct { // Shut down our inspector self.freeInspector(); + // Free our title + if (self.title) |v| self.app.core_app.alloc.free(v); + // Remove ourselves from the list of known surfaces in the app. self.app.core_app.deleteSurface(self); @@ -537,12 +544,22 @@ pub const Surface = struct { } pub fn setTitle(self: *Surface, slice: [:0]const u8) !void { + // Dupe the title so that we can store it. If we get an allocation + // error we just ignore it, since this only breaks a few minor things. + const alloc = self.app.core_app.alloc; + if (self.title) |v| alloc.free(v); + self.title = alloc.dupeZ(u8, slice) catch null; + self.app.opts.set_title( self.userdata, slice.ptr, ); } + pub fn getTitle(self: *Surface) ?[:0]const u8 { + return self.title; + } + 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..b05da88ff 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,12 @@ pub const Message = union(enum) { /// Report the color scheme report_color_scheme: void, + + pub const ReportTitleStyle = enum { + csi_21_t, + + // This enum is a placeholder for future title styles. + }; }; /// A surface mailbox. diff --git a/src/terminal/csi.zig b/src/terminal/csi.zig index 877f5986e..d2ee4da46 100644 --- a/src/terminal/csi.zig +++ b/src/terminal/csi.zig @@ -1,4 +1,4 @@ -// Modes for the ED CSI command. +/// Modes for the ED CSI command. pub const EraseDisplay = enum(u8) { below = 0, above = 1, @@ -10,7 +10,7 @@ pub const EraseDisplay = enum(u8) { scroll_complete = 22, }; -// Modes for the EL CSI command. +/// Modes for the EL CSI command. pub const EraseLine = enum(u8) { right = 0, left = 1, @@ -22,7 +22,7 @@ pub const EraseLine = enum(u8) { _, }; -// Modes for the TBC (tab clear) command. +/// Modes for the TBC (tab clear) command. pub const TabClear = enum(u8) { current = 0, all = 3, @@ -31,3 +31,12 @@ pub const TabClear = enum(u8) { // user-generated. _, }; + +/// Style formats for terminal size reports. +pub const SizeReportStyle = enum { + // XTWINOPS + csi_14_t, + csi_16_t, + csi_18_t, + csi_21_t, +}; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index b486a8da5..cdf80a54b 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -38,6 +38,7 @@ pub const Pin = PageList.Pin; pub const Screen = @import("Screen.zig"); pub const ScreenType = Terminal.ScreenType; pub const Selection = @import("Selection.zig"); +pub const SizeReportStyle = csi.SizeReportStyle; pub const StringMap = @import("StringMap.zig"); pub const Style = style.Style; pub const Terminal = @import("Terminal.zig"); diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index a9c802965..6adfa3280 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1109,6 +1109,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, "sendSizeReport")) { + self.handler.sendSizeReport(.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, "sendSizeReport")) { + self.handler.sendSizeReport(.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, "sendSizeReport")) { + self.handler.sendSizeReport(.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, "sendSizeReport")) { + self.handler.sendSizeReport(.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 +2116,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: ?csi.SizeReportStyle = null, + + pub fn sendSizeReport(self: *@This(), style: csi.SizeReportStyle) void { + self.style = style; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + + try s.nextSlice("\x1b[14t"); + try testing.expectEqual(csi.SizeReportStyle.csi_14_t, s.handler.style); + + try s.nextSlice("\x1b[16t"); + try testing.expectEqual(csi.SizeReportStyle.csi_16_t, s.handler.style); + + try s.nextSlice("\x1b[18t"); + try testing.expectEqual(csi.SizeReportStyle.csi_18_t, s.handler.style); + + try s.nextSlice("\x1b[21t"); + try testing.expectEqual(csi.SizeReportStyle.csi_21_t, s.handler.style); +} + +test "stream: invalid CSI t" { + const H = struct { + style: ?csi.SizeReportStyle = null, + + pub fn sendSizeReport(self: *@This(), style: csi.SizeReportStyle) 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..a5307498e 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.html#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..648efb6fb 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 sendSizeReport(self: *StreamHandler, style: terminal.SizeReportStyle) 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 }), + } + } };