diff --git a/include/ghostty.h b/include/ghostty.h index 6c3f5af64..9b7a918ec 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -700,6 +700,7 @@ typedef struct { typedef enum { GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, + GHOSTTY_ACTION_OPEN_URL_KIND_HTML, } ghostty_action_open_url_kind_e; // apprt.action.OpenUrl.C diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 4921ef8df..9d389a8c2 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -45,11 +45,14 @@ extension Ghostty.Action { enum Kind { case unknown case text + case html init(_ c: ghostty_action_open_url_kind_e) { switch c { case GHOSTTY_ACTION_OPEN_URL_KIND_TEXT: self = .text + case GHOSTTY_ACTION_OPEN_URL_KIND_HTML: + self = .html default: self = .unknown } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 074b0f6d5..466e7859d 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -676,6 +676,10 @@ extension Ghostty { return true } + case .html: + // The extension will be HTML and we do the right thing automatically. + break + case .unknown: break } diff --git a/src/Surface.zig b/src/Surface.zig index 6d5c9b683..cbc3c0cee 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5280,14 +5280,24 @@ const WriteScreenLoc = enum { fn writeScreenFile( self: *Surface, loc: WriteScreenLoc, - write_action: input.Binding.Action.WriteScreenAction, + write_screen: input.Binding.Action.WriteScreen, ) !void { // Create a temporary directory to store our scrollback. var tmp_dir = try internal_os.TempDir.init(); errdefer tmp_dir.deinit(); var filename_buf: [std.fs.max_path_bytes]u8 = undefined; - const filename = try std.fmt.bufPrint(&filename_buf, "{s}.txt", .{@tagName(loc)}); + const filename = try std.fmt.bufPrint( + &filename_buf, + "{s}.{s}", + .{ + @tagName(loc), + switch (write_screen.emit) { + .plain, .vt => "txt", + .html => "html", + }, + }, + ); // Open our scrollback file var file = try tmp_dir.dir.createFile( @@ -5347,18 +5357,24 @@ fn writeScreenFile( return; }; - // Use topLeft and bottomRight to ensure correct coordinate ordering - const tl = sel.topLeft(&self.io.terminal.screen); - const br = sel.bottomRight(&self.io.terminal.screen); - - try self.io.terminal.screen.dumpString( - buf_writer, - .{ - .tl = tl, - .br = br, - .unwrap = true, + const ScreenFormatter = terminal.formatter.ScreenFormatter; + var formatter: ScreenFormatter = .init(&self.io.terminal.screen, .{ + .emit = switch (write_screen.emit) { + .plain => .plain, + .vt => .vt, + .html => .html, }, - ); + .unwrap = true, + .trim = false, + .background = self.io.terminal.colors.background.get(), + .foreground = self.io.terminal.colors.foreground.get(), + .palette = &self.io.terminal.colors.palette.current, + }); + formatter.content = .{ .selection = sel.ordered( + &self.io.terminal.screen, + .forward, + ) }; + try formatter.format(buf_writer); } try buf_writer.flush(); @@ -5366,7 +5382,7 @@ fn writeScreenFile( var path_buf: [std.fs.max_path_bytes]u8 = undefined; const path = try tmp_dir.dir.realpath(filename, &path_buf); - switch (write_action) { + switch (write_screen.action) { .copy => { const pathZ = try self.alloc.dupeZ(u8, path); defer self.alloc.free(pathZ); @@ -5375,7 +5391,13 @@ fn writeScreenFile( .data = pathZ, }}, false); }, - .open => try self.openUrl(.{ .kind = .text, .url = path }), + .open => try self.openUrl(.{ + .kind = switch (write_screen.emit) { + .plain, .vt => .text, + .html => .html, + }, + .url = path, + }), .paste => self.io.queueMessage(try termio.Message.writeReq( self.alloc, path, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index e593d4bce..1c286e98d 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -724,6 +724,9 @@ pub const OpenUrl = struct { /// should try to open the URL in a text editor or viewer or /// some equivalent, if possible. text, + + /// The URL is known to contain HTML content. + html, }; // Sync with: ghostty_action_open_url_s diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 26278f386..94868c2c1 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -434,13 +434,13 @@ pub const Action = union(enum) { /// The default OS editor is determined by using `open` on macOS /// and `xdg-open` on Linux. /// - write_scrollback_file: WriteScreenAction, + write_scrollback_file: WriteScreen, /// Write the contents of the screen into a temporary file with the /// specified action. /// /// See `write_scrollback_file` for possible actions. - write_screen_file: WriteScreenAction, + write_screen_file: WriteScreen, /// Write the currently selected text into a temporary file with the /// specified action. @@ -448,7 +448,7 @@ pub const Action = union(enum) { /// See `write_scrollback_file` for possible actions. /// /// Does nothing when no text is selected. - write_selection_file: WriteScreenAction, + write_selection_file: WriteScreen, /// Open a new window. /// @@ -811,6 +811,15 @@ pub const Action = union(enum) { .application = try alloc.dupe(u8, self.application), }; } + + pub fn format( + self: CursorKey, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + _ = self; + _ = writer; + @panic("formatting not supported"); + } }; pub const AdjustSelection = enum { @@ -902,10 +911,64 @@ pub const Action = union(enum) { pub const default: CopyToClipboard = .mixed; }; - pub const WriteScreenAction = enum { - copy, - paste, - open, + pub const WriteScreen = struct { + action: WriteScreen.Action, + emit: WriteScreen.Format, + + pub const copy: WriteScreen = .{ .action = .copy, .emit = .plain }; + pub const paste: WriteScreen = .{ .action = .paste, .emit = .plain }; + pub const open: WriteScreen = .{ .action = .open, .emit = .plain }; + + pub const Action = enum { + copy, + paste, + open, + }; + + pub const Format = enum { + plain, + vt, + html, + }; + + pub fn parse(param: []const u8) !WriteScreen { + // If we don't have a `,`, default to the plain format. This is + // also very important for backwards compatibility before Ghostty + // 1.3 which didn't support output formats. + const idx = std.mem.indexOfScalar(u8, param, ',') orelse return .{ + .action = try Binding.Action.parseEnum( + WriteScreen.Action, + param, + ), + .emit = .plain, + }; + + return .{ + .action = try Binding.Action.parseEnum( + WriteScreen.Action, + param[0..idx], + ), + .emit = try Binding.Action.parseEnum( + WriteScreen.Format, + param[idx + 1 ..], + ), + }; + } + + pub fn clone( + self: WriteScreen, + alloc: Allocator, + ) Allocator.Error!WriteScreen { + _ = alloc; + return self; + } + + pub fn format(self: WriteScreen, writer: *std.Io.Writer) std.Io.Writer.Error!void { + try writer.print("{t},{t}", .{ + self.action, + self.emit, + }); + } }; // Extern because it is used in the embedded runtime ABI. @@ -948,7 +1011,7 @@ pub const Action = union(enum) { if (@hasDecl(field.type, "parse") and @typeInfo(@TypeOf(field.type.parse)) == .@"fn") { - return field.type.parse(param); + return try field.type.parse(param); } } @@ -1244,12 +1307,19 @@ pub const Action = union(enum) { .@"enum" => try writer.print("{t}", .{value}), .float => try writer.print("{d}", .{value}), .int => try writer.print("{d}", .{value}), - .@"struct" => |info| if (!info.is_tuple) { - try writer.print("{} (not configurable)", .{value}); - } else { - inline for (info.fields, 0..) |field, i| { - try formatValue(writer, @field(value, field.name)); - if (i + 1 < info.fields.len) try writer.writeAll(","); + .@"struct" => |info| format: { + if (@hasDecl(Value, "format")) { + try value.format(writer); + break :format; + } + + if (!info.is_tuple) { + @compileError("unhandled struct type: " ++ @typeName(Value)); + } else { + inline for (info.fields, 0..) |field, i| { + try formatValue(writer, @field(value, field.name)); + if (i + 1 < info.fields.len) try writer.writeAll(","); + } } }, else => @compileError("unhandled type: " ++ @typeName(Value)), @@ -3274,6 +3344,62 @@ test "parse: copy to clipboard explicit" { } } +test "parse: write screen file no format" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=write_screen_file:copy"); + try testing.expect(binding.action == .write_screen_file); + try testing.expectEqual(Action.WriteScreen.copy, binding.action.write_screen_file); + } +} + +test "parse: write screen file format" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=write_screen_file:copy,html"); + try testing.expect(binding.action == .write_screen_file); + try testing.expectEqual(Action.WriteScreen{ + .action = .copy, + .emit = .html, + }, binding.action.write_screen_file); + } +} + +test "parse: write screen file format as string" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + const binding = try parseSingle("a=write_screen_file:copy,html"); + try binding.action.format(&buf.writer); + try testing.expectEqualStrings("write_screen_file:copy,html", buf.written()); + } +} + +test "parse: write screen file invalid" { + const testing = std.testing; + + // paramet r + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:", + )); + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:,", + )); + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:copy,", + )); + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:copy,html,extra", + )); +} + test "action: format" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/input/command.zig b/src/input/command.zig index b97b98cca..f38295a4f 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -239,6 +239,56 @@ fn actionCommands(action: Action.Key) []const Command { .title = "Copy Screen to Temporary File and Open", .description = "Copy the screen contents to a temporary file and open it.", }, + + .{ + .action = .{ .write_screen_file = .{ + .action = .copy, + .emit = .html, + } }, + .title = "Copy Screen as HTML to Temporary File and Copy Path", + .description = "Copy the screen contents as HTML to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .paste, + .emit = .html, + } }, + .title = "Copy Screen as HTML to Temporary File and Paste Path", + .description = "Copy the screen contents as HTML to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .open, + .emit = .html, + } }, + .title = "Copy Screen as HTML to Temporary File and Open", + .description = "Copy the screen contents as HTML to a temporary file and open it.", + }, + + .{ + .action = .{ .write_screen_file = .{ + .action = .copy, + .emit = .vt, + } }, + .title = "Copy Screen as ANSI Sequences to Temporary File and Copy Path", + .description = "Copy the screen contents as ANSI escape sequences to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .paste, + .emit = .vt, + } }, + .title = "Copy Screen as ANSI Sequences to Temporary File and Paste Path", + .description = "Copy the screen contents as ANSI escape sequences to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .open, + .emit = .vt, + } }, + .title = "Copy Screen as ANSI Sequences to Temporary File and Open", + .description = "Copy the screen contents as ANSI escape sequences to a temporary file and open it.", + }, }, .write_selection_file => comptime &.{ @@ -257,6 +307,56 @@ fn actionCommands(action: Action.Key) []const Command { .title = "Copy Selection to Temporary File and Open", .description = "Copy the selection contents to a temporary file and open it.", }, + + .{ + .action = .{ .write_selection_file = .{ + .action = .copy, + .emit = .html, + } }, + .title = "Copy Selection as HTML to Temporary File and Copy Path", + .description = "Copy the selection contents as HTML to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .paste, + .emit = .html, + } }, + .title = "Copy Selection as HTML to Temporary File and Paste Path", + .description = "Copy the selection contents as HTML to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .open, + .emit = .html, + } }, + .title = "Copy Selection as HTML to Temporary File and Open", + .description = "Copy the selection contents as HTML to a temporary file and open it.", + }, + + .{ + .action = .{ .write_selection_file = .{ + .action = .copy, + .emit = .vt, + } }, + .title = "Copy Selection as ANSI Sequences to Temporary File and Copy Path", + .description = "Copy the selection contents as ANSI escape sequences to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .paste, + .emit = .vt, + } }, + .title = "Copy Selection as ANSI Sequences to Temporary File and Paste Path", + .description = "Copy the selection contents as ANSI escape sequences to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .open, + .emit = .vt, + } }, + .title = "Copy Selection as ANSI Sequences to Temporary File and Open", + .description = "Copy the selection contents as ANSI escape sequences to a temporary file and open it.", + }, }, .new_window => comptime &.{.{ diff --git a/src/os/open.zig b/src/os/open.zig index 9b069c80f..28d1c23ee 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -34,7 +34,7 @@ pub fn open( .macos => .init( switch (kind) { .text => &.{ "open", "-t", url }, - .unknown => &.{ "open", url }, + .html, .unknown => &.{ "open", url }, }, alloc, ),