diff --git a/src/App.zig b/src/App.zig index f933b7126..c70c41d3a 100644 --- a/src/App.zig +++ b/src/App.zig @@ -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, }; }; diff --git a/src/Surface.zig b/src/Surface.zig index 4b5b3f98c..484b0f3e2 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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) { - .right => .right, - .down => .down, - .auto => if (self.screen_size.width > self.screen_size.height) - .right - else - .down, - }); + 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); + }, } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index d3b2d6f29..f636b71ea 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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); } diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 89448c354..cb5d67800 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -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. diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 8c4e41111..aab8c70a8 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -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 { diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig index 105646c7c..fbf81a764 100644 --- a/src/apprt/gtk/Split.zig +++ b/src/apprt/gtk/Split.zig @@ -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(); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 7ae690ce5..482d16e01 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -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 }), + } +} diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index 42a18711b..3760ba855 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -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 }; diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 1615b986f..da0dc92f9 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -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 diff --git a/src/config/Config.zig b/src/config/Config.zig index a5d1c1e68..10dd454cc 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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 diff --git a/src/input/Binding.zig b/src/input/Binding.zig index ef0f8e4c1..48e54b8fb 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -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. diff --git a/src/os/editor.zig b/src/os/editor.zig new file mode 100644 index 000000000..aed1807d8 --- /dev/null +++ b/src/os/editor.zig @@ -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"); +} diff --git a/src/os/main.zig b/src/os/main.zig index 42bb0cdeb..8d6557b68 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -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;