input: write_*_file actions take an optional format

Fixes #9398
pull/9428/head
Mitchell Hashimoto 2025-10-31 09:31:56 -07:00
parent 24b9778432
commit 901708e8da
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
8 changed files with 289 additions and 30 deletions

View File

@ -700,6 +700,7 @@ typedef struct {
typedef enum { typedef enum {
GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN,
GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT,
GHOSTTY_ACTION_OPEN_URL_KIND_HTML,
} ghostty_action_open_url_kind_e; } ghostty_action_open_url_kind_e;
// apprt.action.OpenUrl.C // apprt.action.OpenUrl.C

View File

@ -45,11 +45,14 @@ extension Ghostty.Action {
enum Kind { enum Kind {
case unknown case unknown
case text case text
case html
init(_ c: ghostty_action_open_url_kind_e) { init(_ c: ghostty_action_open_url_kind_e) {
switch c { switch c {
case GHOSTTY_ACTION_OPEN_URL_KIND_TEXT: case GHOSTTY_ACTION_OPEN_URL_KIND_TEXT:
self = .text self = .text
case GHOSTTY_ACTION_OPEN_URL_KIND_HTML:
self = .html
default: default:
self = .unknown self = .unknown
} }

View File

@ -676,6 +676,10 @@ extension Ghostty {
return true return true
} }
case .html:
// The extension will be HTML and we do the right thing automatically.
break
case .unknown: case .unknown:
break break
} }

View File

@ -5280,14 +5280,24 @@ const WriteScreenLoc = enum {
fn writeScreenFile( fn writeScreenFile(
self: *Surface, self: *Surface,
loc: WriteScreenLoc, loc: WriteScreenLoc,
write_action: input.Binding.Action.WriteScreenAction, write_screen: input.Binding.Action.WriteScreen,
) !void { ) !void {
// Create a temporary directory to store our scrollback. // Create a temporary directory to store our scrollback.
var tmp_dir = try internal_os.TempDir.init(); var tmp_dir = try internal_os.TempDir.init();
errdefer tmp_dir.deinit(); errdefer tmp_dir.deinit();
var filename_buf: [std.fs.max_path_bytes]u8 = undefined; 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 // Open our scrollback file
var file = try tmp_dir.dir.createFile( var file = try tmp_dir.dir.createFile(
@ -5347,18 +5357,24 @@ fn writeScreenFile(
return; return;
}; };
// Use topLeft and bottomRight to ensure correct coordinate ordering const ScreenFormatter = terminal.formatter.ScreenFormatter;
const tl = sel.topLeft(&self.io.terminal.screen); var formatter: ScreenFormatter = .init(&self.io.terminal.screen, .{
const br = sel.bottomRight(&self.io.terminal.screen); .emit = switch (write_screen.emit) {
.plain => .plain,
try self.io.terminal.screen.dumpString( .vt => .vt,
buf_writer, .html => .html,
.{
.tl = tl,
.br = br,
.unwrap = true,
}, },
); .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(); try buf_writer.flush();
@ -5366,7 +5382,7 @@ fn writeScreenFile(
var path_buf: [std.fs.max_path_bytes]u8 = undefined; var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try tmp_dir.dir.realpath(filename, &path_buf); const path = try tmp_dir.dir.realpath(filename, &path_buf);
switch (write_action) { switch (write_screen.action) {
.copy => { .copy => {
const pathZ = try self.alloc.dupeZ(u8, path); const pathZ = try self.alloc.dupeZ(u8, path);
defer self.alloc.free(pathZ); defer self.alloc.free(pathZ);
@ -5375,7 +5391,13 @@ fn writeScreenFile(
.data = pathZ, .data = pathZ,
}}, false); }}, 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( .paste => self.io.queueMessage(try termio.Message.writeReq(
self.alloc, self.alloc,
path, path,

View File

@ -724,6 +724,9 @@ pub const OpenUrl = struct {
/// should try to open the URL in a text editor or viewer or /// should try to open the URL in a text editor or viewer or
/// some equivalent, if possible. /// some equivalent, if possible.
text, text,
/// The URL is known to contain HTML content.
html,
}; };
// Sync with: ghostty_action_open_url_s // Sync with: ghostty_action_open_url_s

View File

@ -434,13 +434,13 @@ pub const Action = union(enum) {
/// The default OS editor is determined by using `open` on macOS /// The default OS editor is determined by using `open` on macOS
/// and `xdg-open` on Linux. /// 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 /// Write the contents of the screen into a temporary file with the
/// specified action. /// specified action.
/// ///
/// See `write_scrollback_file` for possible actions. /// 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 /// Write the currently selected text into a temporary file with the
/// specified action. /// specified action.
@ -448,7 +448,7 @@ pub const Action = union(enum) {
/// See `write_scrollback_file` for possible actions. /// See `write_scrollback_file` for possible actions.
/// ///
/// Does nothing when no text is selected. /// Does nothing when no text is selected.
write_selection_file: WriteScreenAction, write_selection_file: WriteScreen,
/// Open a new window. /// Open a new window.
/// ///
@ -811,6 +811,15 @@ pub const Action = union(enum) {
.application = try alloc.dupe(u8, self.application), .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 { pub const AdjustSelection = enum {
@ -902,10 +911,64 @@ pub const Action = union(enum) {
pub const default: CopyToClipboard = .mixed; pub const default: CopyToClipboard = .mixed;
}; };
pub const WriteScreenAction = enum { pub const WriteScreen = struct {
copy, action: WriteScreen.Action,
paste, emit: WriteScreen.Format,
open,
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. // 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 if (@hasDecl(field.type, "parse") and
@typeInfo(@TypeOf(field.type.parse)) == .@"fn") @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}), .@"enum" => try writer.print("{t}", .{value}),
.float => try writer.print("{d}", .{value}), .float => try writer.print("{d}", .{value}),
.int => try writer.print("{d}", .{value}), .int => try writer.print("{d}", .{value}),
.@"struct" => |info| if (!info.is_tuple) { .@"struct" => |info| format: {
try writer.print("{} (not configurable)", .{value}); if (@hasDecl(Value, "format")) {
} else { try value.format(writer);
inline for (info.fields, 0..) |field, i| { break :format;
try formatValue(writer, @field(value, field.name)); }
if (i + 1 < info.fields.len) try writer.writeAll(",");
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)), 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" { test "action: format" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;

View File

@ -239,6 +239,56 @@ fn actionCommands(action: Action.Key) []const Command {
.title = "Copy Screen to Temporary File and Open", .title = "Copy Screen to Temporary File and Open",
.description = "Copy the screen contents to a temporary file and open it.", .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 &.{ .write_selection_file => comptime &.{
@ -257,6 +307,56 @@ fn actionCommands(action: Action.Key) []const Command {
.title = "Copy Selection to Temporary File and Open", .title = "Copy Selection to Temporary File and Open",
.description = "Copy the selection contents to a temporary file and open it.", .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 &.{.{ .new_window => comptime &.{.{

View File

@ -34,7 +34,7 @@ pub fn open(
.macos => .init( .macos => .init(
switch (kind) { switch (kind) {
.text => &.{ "open", "-t", url }, .text => &.{ "open", "-t", url },
.unknown => &.{ "open", url }, .html, .unknown => &.{ "open", url },
}, },
alloc, alloc,
), ),