Open scrollback in your editor inside Ghostty

This adds options to `write_scrollback_file`, `write_screen_file`, and
`write_selection_file` to create a new window, tab, or split and open
up that file using your editor.

To do so, the plumbing has been added to the core to create new surfaces
with a custom configuration.
This commit is contained in:
Jeffrey C. Ollie
2024-08-16 23:48:46 -05:00
parent dd9e1d9fa7
commit 7b400c8367
13 changed files with 252 additions and 47 deletions

View File

@ -253,7 +253,7 @@ pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void {
null;
} else null;
try rt_app.newWindow(parent);
try rt_app.newWindow(.{ .parent = parent, .config = msg.config });
}
/// Start quitting
@ -321,6 +321,9 @@ pub const Message = union(enum) {
const NewWindow = struct {
/// The parent surface
parent: ?*Surface = null,
/// Custom configuration to use for the new window.
config: ?*Config = null,
};
};

View File

@ -3399,7 +3399,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.new_tab => {
if (@hasDecl(apprt.Surface, "newTab")) {
try self.rt_surface.newTab();
try self.rt_surface.newTab(.{});
} else log.warn("runtime doesn't implement newTab", .{});
},
@ -3437,14 +3437,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.new_split => |direction| {
if (@hasDecl(apprt.Surface, "newSplit")) {
try self.rt_surface.newSplit(switch (direction) {
try self.rt_surface.newSplit(
switch (direction) {
.right => .right,
.down => .down,
.auto => if (self.screen_size.width > self.screen_size.height)
.right
else
.down,
});
},
.{},
);
} else log.warn("runtime doesn't implement newSplit", .{});
},
@ -3663,6 +3666,22 @@ fn writeScreenFile(
self.alloc,
path,
), .unlocked),
.edit_window => {
if (@hasDecl(apprt.runtime.Surface, "openEditorWithPath"))
try self.rt_surface.openEditorWithPath(.window, path);
},
.edit_tab => {
if (@hasDecl(apprt.runtime.Surface, "openEditorWithPath"))
try self.rt_surface.openEditorWithPath(.tab, path);
},
.edit_split_right => {
if (@hasDecl(apprt.runtime.Surface, "openEditorWithPath"))
try self.rt_surface.openEditorWithPath(.split_right, path);
},
.edit_split_down => {
if (@hasDecl(apprt.runtime.Surface, "openEditorWithPath"))
try self.rt_surface.openEditorWithPath(.split_down, path);
},
}
}

View File

@ -225,13 +225,18 @@ pub const App = struct {
surface.queueInspectorRender();
}
pub fn newWindow(self: *App, parent: ?*CoreSurface) !void {
pub fn newWindow(self: *App, opts: struct {
parent: ?*CoreSurface = null,
config: ?*Config = null,
}) !void {
_ = self;
if (opts.config != null) log.warn("embedded runtime does not support creating new windows with custom configs", .{});
// Right now we only support creating a new window with a parent
// through this code.
// The other case is handled by the embedding runtime.
if (parent) |surface| {
if (opts.parent) |surface| {
try surface.rt_surface.newWindow();
}
}
@ -474,12 +479,16 @@ pub const Surface = struct {
func(self.userdata, mode);
}
pub fn newSplit(self: *const Surface, direction: apprt.SplitDirection) !void {
pub fn newSplit(self: *const Surface, direction: apprt.SplitDirection, opts: struct {
config: ?*Config = null,
}) !void {
const func = self.app.opts.new_split orelse {
log.info("runtime embedder does not support splits", .{});
return;
};
if (opts.config != null) log.warn("embedded runtime does not support creating new splits with a custom config", .{});
const options = self.newSurfaceOptions();
func(self.userdata, direction, options);
}
@ -1035,12 +1044,16 @@ pub const Surface = struct {
func(self.userdata, nonNativeFullscreen);
}
pub fn newTab(self: *const Surface) !void {
pub fn newTab(self: *const Surface, opts: struct {
config: ?*Config = null,
}) !void {
const func = self.app.opts.new_tab orelse {
log.info("runtime embedder does not support new_tab", .{});
return;
};
if (opts.config != null) log.warn("embedded runtime does not support creating new tabs with a custom config", .{});
const options = self.newSurfaceOptions();
func(self.userdata, options);
}

View File

@ -196,22 +196,31 @@ pub const App = struct {
}
/// Create a new window for the app.
pub fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
_ = try self.newSurface(parent_);
pub fn newWindow(self: *App, opts: struct {
parent: ?*CoreSurface = null,
config: ?*Config = null,
}) !void {
if (opts.config != null) log.warn("glfw runtime does not support creating new windows with a custom config", .{});
_ = try self.newSurface(opts.parent);
}
/// Create a new tab in the parent surface.
fn newTab(self: *App, parent: *CoreSurface) !void {
fn newTab(self: *App, opts: struct {
parent: *CoreSurface,
config: ?*Config = null,
}) !void {
if (!Darwin.enabled) {
log.warn("tabbing is not supported on this platform", .{});
return;
}
if (opts.config == null) log.warn("glfw runtime does not support creating new tabs with a custom config", .{});
// Create the new window
const window = try self.newSurface(parent);
const window = try self.newSurface(opts.parent);
// Add the new window the parent window
const parent_win = glfwNative.getCocoaWindow(parent.rt_surface.window).?;
const parent_win = glfwNative.getCocoaWindow(opts.parent.rt_surface.window).?;
const other_win = glfwNative.getCocoaWindow(window.window).?;
const NSWindowOrderingMode = enum(isize) { below = -1, out = 0, above = 1 };
const nswindow = objc.Object.fromId(parent_win);
@ -224,11 +233,11 @@ pub const App = struct {
// our viewport size. We need to call the size callback in order to
// update values. For example, we need this to set the proper mouse selection
// point in the grid.
const size = parent.rt_surface.getSize() catch |err| {
const size = opts.parent.rt_surface.getSize() catch |err| {
log.err("error querying window size for size callback on new tab err={}", .{err});
return;
};
parent.sizeCallback(size) catch |err| {
opts.parent.sizeCallback(size) catch |err| {
log.err("error in size callback from new tab err={}", .{err});
return;
};
@ -526,8 +535,10 @@ pub const Surface = struct {
}
/// Create a new tab in the window containing this surface.
pub fn newTab(self: *Surface) !void {
try self.app.newTab(&self.core_surface);
pub fn newTab(self: *Surface, opts: struct {
config: ?*Config = null,
}) !void {
try self.app.newTab(.{ .parent = &self.core_surface, .config = opts.config });
}
/// Checks if the glfw window is in fullscreen.

View File

@ -565,7 +565,10 @@ pub fn redrawInspector(self: *App, surface: *Surface) void {
}
/// Called by CoreApp to create a new window with a new surface.
pub fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
pub fn newWindow(self: *App, opts: struct {
parent: ?*CoreSurface = null,
config: ?*configpkg.Config = null,
}) !void {
const alloc = self.core_app.alloc;
// Allocate a fixed pointer for our window. We try to minimize
@ -578,7 +581,7 @@ pub fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
var window = try Window.create(alloc, self);
// Add our initial tab
try window.newTab(parent_);
try window.newTab(.{ .parent = opts.parent, .config = opts.config });
}
fn quit(self: *App) void {

View File

@ -9,6 +9,7 @@ const apprt = @import("../../apprt.zig");
const font = @import("../../font/main.zig");
const input = @import("../../input.zig");
const CoreSurface = @import("../../Surface.zig");
const Config = @import("../../config.zig").Config;
const Surface = @import("Surface.zig");
const Tab = @import("Tab.zig");
@ -59,10 +60,13 @@ pub fn create(
alloc: Allocator,
sibling: *Surface,
direction: apprt.SplitDirection,
opts: struct {
config: ?*Config = null,
},
) !*Split {
var split = try alloc.create(Split);
errdefer alloc.destroy(split);
try split.init(sibling, direction);
try split.init(sibling, direction, .{ .config = opts.config });
return split;
}
@ -70,11 +74,15 @@ pub fn init(
self: *Split,
sibling: *Surface,
direction: apprt.SplitDirection,
opts: struct {
config: ?*Config = null,
},
) !void {
// Create the new child surface for the other direction.
const alloc = sibling.app.core_app.alloc;
var surface = try Surface.create(alloc, sibling.app, .{
.parent = &sibling.core_surface,
.config = opts.config,
});
errdefer surface.destroy(alloc);
sibling.dimSurface();

View File

@ -4,6 +4,8 @@
const Surface = @This();
const std = @import("std");
const builtin = @import("builtin");
const build_options = @import("build_options");
const Allocator = std.mem.Allocator;
const configpkg = @import("../../config.zig");
const apprt = @import("../../apprt.zig");
@ -34,6 +36,9 @@ pub const Options = struct {
/// The parent surface to inherit settings such as font size, working
/// directory, etc. from.
parent: ?*CoreSurface = null,
/// A custom config to use in the surface.
config: ?*configpkg.Config = null,
};
/// The container that this surface is directly attached to.
@ -310,6 +315,8 @@ realized: bool = false,
/// True if this surface had a parent to start with.
parent_surface: bool = false,
config: ?*configpkg.Config = null,
/// The GUI container that this surface has been attached to. This
/// dictates some behaviors such as new splits, etc.
container: Container = .{ .none = {} },
@ -455,6 +462,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
// Inherit the parent's font size if we have a parent.
const font_size: ?font.face.DesiredSize = font_size: {
if (opts.config) |config| if (!config.@"window-inherit-font-size") break :font_size null;
if (!app.config.@"window-inherit-font-size") break :font_size null;
const parent = opts.parent orelse break :font_size null;
break :font_size parent.font_size;
@ -501,6 +509,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
.core_surface = undefined,
.font_size = font_size,
.parent_surface = opts.parent != null,
.config = opts.config,
.size = .{ .width = 800, .height = 600 },
.cursor_pos = .{ .x = 0, .y = 0 },
.im_context = im_context,
@ -548,7 +557,10 @@ fn realize(self: *Surface) !void {
errdefer self.app.core_app.deleteSurface(self);
// Get our new surface config
var config = try apprt.surface.newConfig(self.app.core_app, &self.app.config);
var config = cfg: {
if (self.config) |config| break :cfg try apprt.surface.newConfig(self.app.core_app, config);
break :cfg try apprt.surface.newConfig(self.app.core_app, &self.app.config);
};
defer config.deinit();
if (!self.parent_surface) {
// A hack, see the "parent_surface" field for more information.
@ -603,6 +615,11 @@ pub fn deinit(self: *Surface) void {
self.unfocused_widget = null;
}
self.resize_overlay.deinit();
if (self.config) |config| {
config.deinit();
self.app.core_app.alloc.destroy(config);
}
}
// unref removes the long-held reference to the gl_area and kicks off the
@ -749,9 +766,18 @@ pub fn getTitleLabel(self: *Surface) ?*c.GtkWidget {
}
}
pub fn newSplit(self: *Surface, direction: apprt.SplitDirection) !void {
pub fn newSplit(
self: *Surface,
direction: apprt.SplitDirection,
opts: struct { config: ?*configpkg.Config = null },
) !void {
const alloc = self.app.core_app.alloc;
_ = try Split.create(alloc, self, direction);
_ = try Split.create(
alloc,
self,
direction,
.{ .config = opts.config },
);
}
pub fn gotoSplit(self: *const Surface, direction: input.SplitFocusDirection) void {
@ -781,13 +807,18 @@ pub fn equalizeSplits(self: *const Surface) void {
_ = top_split.equalize();
}
pub fn newTab(self: *Surface) !void {
pub fn newTab(self: *Surface, opts: struct {
config: ?*configpkg.Config = null,
}) !void {
const window = self.container.window() orelse {
log.info("surface cannot create new tab when not attached to a window", .{});
return;
};
try window.newTab(&self.core_surface);
try window.newTab(.{
.parent = &self.core_surface,
.config = opts.config,
});
}
pub fn hasTabs(self: *const Surface) bool {
@ -1986,3 +2017,34 @@ fn translateMods(state: c.GdkModifierType) input.Mods {
if (state & c.GDK_LOCK_MASK != 0) mods.caps_lock = true;
return mods;
}
pub fn openEditorWithPath(
self: *Surface,
location: enum {
window,
tab,
split_right,
split_down,
},
path: []const u8,
) !void {
const alloc = self.app.core_app.alloc;
const config = try alloc.create(configpkg.Config);
config.* = try self.app.config.clone(alloc);
const editor = try internal_os.getEditor(alloc, config);
defer alloc.free(editor);
config.command = try std.fmt.allocPrint(
config._arena.?.allocator(),
"{s} {s}",
.{ editor, path },
);
switch (location) {
.window => try self.app.newWindow(.{ .parent = &self.core_surface, .config = config }),
.tab => try self.newTab(.{ .config = config }),
.split_right => try self.newSplit(.right, .{ .config = config }),
.split_down => try self.newSplit(.down, .{ .config = config }),
}
}

View File

@ -9,6 +9,7 @@ const assert = std.debug.assert;
const font = @import("../../font/main.zig");
const input = @import("../../input.zig");
const CoreSurface = @import("../../Surface.zig");
const Config = @import("../../config/Config.zig");
const Surface = @import("Surface.zig");
const Window = @import("Window.zig");
@ -37,16 +38,21 @@ elem: Surface.Container.Elem,
// can easily re-focus that terminal.
focus_child: *Surface,
pub fn create(alloc: Allocator, window: *Window, parent_: ?*CoreSurface) !*Tab {
const Options = struct {
parent: ?*CoreSurface = null,
config: ?*Config = null,
};
pub fn create(alloc: Allocator, window: *Window, opts: Options) !*Tab {
var tab = try alloc.create(Tab);
errdefer alloc.destroy(tab);
try tab.init(window, parent_);
try tab.init(window, opts);
return tab;
}
/// Initialize the tab, create a surface, and add it to the window. "self"
/// needs to be a stable pointer, since it is used for GTK events.
pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void {
pub fn init(self: *Tab, window: *Window, opts: Options) !void {
self.* = .{
.window = window,
.label_text = undefined,
@ -97,7 +103,8 @@ pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void {
// Create the initial surface since all tabs start as a single non-split
var surface = try Surface.create(window.app.core_app.alloc, window.app, .{
.parent = parent_,
.parent = opts.parent,
.config = opts.config,
});
errdefer surface.unref();
surface.container = .{ .tab_ = self };

View File

@ -207,9 +207,12 @@ pub fn deinit(self: *Window) void {
}
/// Add a new tab to this window.
pub fn newTab(self: *Window, parent: ?*CoreSurface) !void {
pub fn newTab(self: *Window, opts: struct {
parent: ?*CoreSurface = null,
config: ?*configpkg.Config = null,
}) !void {
const alloc = self.app.core_app.alloc;
_ = try Tab.create(alloc, self, parent);
_ = try Tab.create(alloc, self, .{ .parent = opts.parent, .config = opts.config });
// TODO: When this is triggered through a GTK action, the new surface
// redraws correctly. When it's triggered through keyboard shortcuts, it

View File

@ -459,6 +459,25 @@ palette: Palette = .{},
/// instance is launched and the CLI args are respected.
command: ?[]const u8 = null,
/// A command to use to open/edit text files in a terminal window (using
/// something like Helix, Flow, Vim, NeoVim, Emacs, or Nano). If this is not set,
/// Ghostty will check the `EDITOR` environment variable for the command. If
/// the `EDITOR` environment variable is not set, Ghostty will fall back to `vi`
/// (similar to how many Linux/Unix systems operate).
///
/// This command will be used to open/edit files when Ghostty receives a signal
/// from the operating system to open a file. Currently implemented on the GTK
/// runtime only.
///
/// The command may contain additional arguments besides the path to the
/// editor's binary. The files that are to be opened will be added to the end of
/// the command. For example, if `editor` was set to `emacs -nx` and you tried
/// to open `README` and `hello.c` in your home directory, the final command
/// would look like
///
/// emacs -nx /home/user/README /home/user/hello.c
editor: ?[]const u8 = null,
/// If true, keep the terminal open after the command exits. Normally, the
/// terminal window closes when the running command (such as a shell) exits.
/// With this true, the terminal window will stay open until any keypress is

View File

@ -227,22 +227,56 @@ pub const Action = union(enum) {
/// number of prompts to jump forward, negative is backwards.
jump_to_prompt: i16,
/// Write the entire scrollback into a temporary file. The action
/// determines what to do with the filepath. Valid values are:
/// Write the entire scrollback into a temporary file. The action determines
/// what to do with the file. Valid values are:
///
/// - "paste": Paste the file path into the terminal.
/// - "open": Open the file in the default OS editor for text files.
/// - `paste`: Paste the file path into the terminal.
/// - `open`: Open the file in the default OS editor for text files.
/// - `edit_window`: Create a new window, and open the file in your editor.
/// - `edit_tab`: Create a new tab, and open the file in your editor.
/// - `edit_split_right`: Create a new split right, and open the file in your editor.
/// - `edit_split_down`: Create a new split down, and open the file in your editor.
///
/// `edit_window`, `edit_tab`, `edit_split_right`, and `edit_split_down` are
/// supported on GTK only.
///
/// See the configuration setting `editor` for more information on how
/// Ghostty determines your editor.
write_scrollback_file: WriteScreenAction,
/// Same as write_scrollback_file but writes the full screen contents.
/// See write_scrollback_file for available values.
/// Write the full screen contents into a temporary file. The action
/// determines what to do with the file. Valid values are:
///
/// - `paste`: Paste the file path into the terminal.
/// - `open`: Open the file in the default OS editor for text files.
/// - `edit_window`: Create a new window, and open the file in your editor.
/// - `edit_tab`: Create a new tab, and open the file in your editor.
/// - `edit_split_right`: Create a new split right, and open the file in your editor.
/// - `edit_split_down`: Create a new split down, and open the file in your editor.
///
/// `edit_window`, `edit_tab`, `edit_split_right`, and `edit_split_down` are
/// supported on GTK only.
///
/// See the configuration setting `editor` for more information on how
/// Ghostty determines your editor.
write_screen_file: WriteScreenAction,
/// Same as write_scrollback_file but writes the selected text.
/// If there is no selected text this does nothing (it doesn't
/// even create an empty file). See write_scrollback_file for
/// available values.
/// Writes the selected text into a temporary file. If there is no selected
/// text this action does nothing (it doesn't even create an empty file).
/// The action determines what to do with the file. Valid values are:
///
/// - `paste`: Paste the file path into the terminal.
/// - `open`: Open the file in the default OS editor for text files.
/// - `edit_window`: Create a new window, and open the file in your editor.
/// - `edit_tab`: Create a new tab, and open the file in your editor.
/// - `edit_split_right`: Create a new split right, and open the file in your editor.
/// - `edit_split_down`: Create a new split down, and open the file in your editor.
///
/// `edit_window`, `edit_tab`, `edit_split_right`, and `edit_split_down` are
/// supported on GTK only.
///
/// See the configuration setting `editor` for more information on how
/// Ghostty determines your editor.
write_selection_file: WriteScreenAction,
/// Open a new window.
@ -367,6 +401,10 @@ pub const Action = union(enum) {
pub const WriteScreenAction = enum {
paste,
open,
edit_window,
edit_tab,
edit_split_right,
edit_split_down,
};
// Extern because it is used in the embedded runtime ABI.

17
src/os/editor.zig Normal file
View File

@ -0,0 +1,17 @@
const std = @import("std");
const builtin = @import("builtin");
const Config = @import("../config.zig").Config;
pub fn getEditor(alloc: std.mem.Allocator, config: *const Config) ![]const u8 {
// figure out what our editor is
if (config.editor) |editor| return try alloc.dupe(u8, editor);
switch (builtin.os.tag) {
.windows => {
if (std.process.getenvW(std.unicode.utf8ToUtf16LeStringLiteral("EDITOR"))) |win_editor| {
return try std.unicode.utf16leToUtf8Alloc(alloc, win_editor);
}
},
else => if (std.posix.getenv("EDITOR")) |editor| return alloc.dupe(u8, editor),
}
return alloc.dupe(u8, "vi");
}

View File

@ -13,6 +13,7 @@ const mouse = @import("mouse.zig");
const openpkg = @import("open.zig");
const pipepkg = @import("pipe.zig");
const resourcesdir = @import("resourcesdir.zig");
const editor = @import("editor.zig");
// Namespaces
pub const cgroup = @import("cgroup.zig");
@ -41,3 +42,4 @@ pub const clickInterval = mouse.clickInterval;
pub const open = openpkg.open;
pub const pipe = pipepkg.pipe;
pub const resourcesDir = resourcesdir.resourcesDir;
pub const getEditor = editor.getEditor;