diff --git a/src/Surface.zig b/src/Surface.zig index e71af3939..9f32b087f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -30,6 +30,7 @@ const font = @import("font/main.zig"); const Command = @import("Command.zig"); const terminal = @import("terminal/main.zig"); const configpkg = @import("config.zig"); +const ConfigOverrides = configpkg.ConfigOverrides; const Duration = configpkg.Config.Duration; const input = @import("input.zig"); const App = @import("App.zig"); @@ -607,10 +608,27 @@ pub fn init( }; // The command we're going to execute - const command: ?configpkg.Command = if (app.first) - config.@"initial-command" orelse config.command - else - config.command; + const command: ?configpkg.Command = command: { + if (self.getConfigOverrides()) |config_overrides| { + if (config_overrides.isSet(.command)) + break :command config_overrides.get(.command); + } + if (app.first) { + if (config.@"initial-command") |command| { + break :command command; + } + } + break :command config.command; + }; + + // The working directory to execute the command in. + const working_directory: ?[]const u8 = wd: { + if (self.getConfigOverrides()) |config_overrides| { + if (config_overrides.isSet(.@"working-directory")) + break :wd config_overrides.get(.@"working-directory"); + } + break :wd config.@"working-directory"; + }; // Start our IO implementation // This separate block ({}) is important because our errdefers must @@ -635,7 +653,7 @@ pub fn init( .shell_integration = config.@"shell-integration", .shell_integration_features = config.@"shell-integration-features", .cursor_blink = config.@"cursor-style-blink", - .working_directory = config.@"working-directory", + .working_directory = working_directory, .resources_dir = global_state.resources_dir.host(), .term = config.term, .rt_pre_exec_info = .init(config), @@ -1789,6 +1807,13 @@ pub fn updateConfig( ); } +fn getConfigOverrides(self: *Surface) ?*const ConfigOverrides { + if (@hasDecl(apprt.runtime.Surface, "getConfigOverrides")) { + return self.rt_surface.getConfigOverrides(); + } + return null; +} + const InitialSizeError = error{ ContentScaleUnavailable, AppActionFailed, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 918e77146..f7a563d14 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2,6 +2,7 @@ const Self = @This(); const std = @import("std"); const apprt = @import("../../apprt.zig"); +const configpkg = @import("../../config.zig"); const CoreSurface = @import("../../Surface.zig"); const ApprtApp = @import("App.zig"); const Application = @import("class/application.zig").Application; @@ -101,3 +102,7 @@ pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { pub fn redrawInspector(self: *Self) void { self.surface.redrawInspector(); } + +pub fn getConfigOverrides(self: *Self) ?*const configpkg.ConfigOverrides { + return self.gobj().getConfigOverrides(); +} diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 00560fd13..8b5fa7094 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -32,6 +32,7 @@ const ApprtApp = @import("../App.zig"); const Common = @import("../class.zig").Common; const WeakRef = @import("../weak_ref.zig").WeakRef; const Config = @import("config.zig").Config; +const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const Surface = @import("surface.zig").Surface; const SplitTree = @import("split_tree.zig").SplitTree; const Window = @import("window.zig").Window; @@ -709,6 +710,7 @@ pub const Application = extern struct { .app => null, .surface => |v| v, }, + null, ), .open_config => return Action.openConfig(self), @@ -1669,17 +1671,29 @@ pub const Application = extern struct { ) callconv(.c) void { log.debug("received new window action", .{}); - parameter: { + const config_overrides: ?*ConfigOverrides = config_overrides: { // were we given a parameter? - const parameter = parameter_ orelse break :parameter; + const parameter = parameter_ orelse break :config_overrides null; + + const alloc = Application.default().allocator(); + const config_overrides = ConfigOverrides.new(alloc) catch |err| { + log.warn("unable to create new config overrides: {t}", .{err}); + break :config_overrides null; + }; + errdefer config_overrides.unref(); + + const co = config_overrides.get(); const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); // ensure that the supplied parameter is an array of strings if (glib.Variant.isOfType(parameter, as_variant_type) == 0) { - log.warn("parameter is of type {s}", .{parameter.getTypeString()}); - break :parameter; + log.warn("parameter is of type '{s}', not '{s}'", .{ + parameter.getTypeString(), + as_variant_type.peekString()[0..as_variant_type.getStringLength()], + }); + break :config_overrides null; } const s_variant_type = glib.VariantType.new("s"); @@ -1688,7 +1702,16 @@ pub const Application = extern struct { var it: glib.VariantIter = undefined; _ = it.init(parameter); - while (it.nextValue()) |value| { + var args: std.ArrayList([:0]const u8) = .empty; + defer { + for (args.items) |arg| alloc.free(arg); + args.deinit(alloc); + } + + var e_seen: bool = false; + var i: usize = 0; + + while (it.nextValue()) |value| : (i += 1) { defer value.unref(); // just to be sure @@ -1698,13 +1721,45 @@ pub const Application = extern struct { const buf = value.getString(&len); const str = buf[0..len]; - log.debug("new-window command argument: {s}", .{str}); + if (e_seen) { + const cpy = alloc.dupeZ(u8, str) catch |err| { + log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err }); + break :config_overrides null; + }; + errdefer alloc.free(cpy); + args.append(alloc, cpy) catch |err| { + log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); + break :config_overrides null; + }; + continue; + } + + if (std.mem.eql(u8, str, "-e")) { + e_seen = true; + continue; + } + + co.parseCLI(str) catch |err| { + log.warn("unable to parse argument {d} {s}: {t}", .{ i, str, err }); + continue; + }; + + log.debug("new-window argument: {d} {s}", .{ i, str }); } - } - _ = self.core().mailbox.push(.{ - .new_window = .{}, - }, .{ .forever = {} }); + if (args.items.len > 0) { + co.set(.command, .{ .direct = args.items }) catch |err| { + log.warn("unable to set command on config overrides: {t}", .{err}); + break :config_overrides null; + }; + } + + break :config_overrides config_overrides; + }; + + defer if (config_overrides) |v| v.unref(); + + Action.newWindow(self, null, config_overrides) catch {}; } pub fn actionOpenConfig( @@ -2151,6 +2206,7 @@ const Action = struct { pub fn newWindow( self: *Application, parent: ?*CoreSurface, + config_overrides: ?*ConfigOverrides, ) !void { // Note that we've requested a window at least once. This is used // to trigger quit on no windows. Note I'm not sure if this is REALLY @@ -2160,13 +2216,14 @@ const Action = struct { self.private().requested_window = true; const win = Window.new(self); - initAndShowWindow(self, win, parent); + initAndShowWindow(self, win, parent, config_overrides); } fn initAndShowWindow( self: *Application, win: *Window, parent: ?*CoreSurface, + config_overrides: ?*ConfigOverrides, ) void { // Setup a binding so that whenever our config changes so does the // window. There's never a time when the window config should be out @@ -2180,7 +2237,7 @@ const Action = struct { ); // Create a new tab with window context (first tab in new window) - win.newTabForWindow(parent); + win.newTabForWindow(parent, config_overrides); // Estimate the initial window size before presenting so the window // manager can position it correctly. @@ -2506,7 +2563,7 @@ const Action = struct { .@"quick-terminal" = true, }); assert(win.isQuickTerminal()); - initAndShowWindow(self, win, null); + initAndShowWindow(self, win, null, null); return true; } diff --git a/src/apprt/gtk/class/config_overrides.zig b/src/apprt/gtk/class/config_overrides.zig new file mode 100644 index 000000000..6ca6ea77e --- /dev/null +++ b/src/apprt/gtk/class/config_overrides.zig @@ -0,0 +1,94 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const gobject = @import("gobject"); + +const configpkg = @import("../../../config.zig"); +const Config = configpkg.Config; + +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_config_overrides); + +/// Wrapper for a ConfigOverrides object that keeps track of which settings have +/// been changed. +pub const ConfigOverrides = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = gobject.Object; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyConfigOverrides", + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct {}; + + const Private = struct { + config_overrides: configpkg.ConfigOverrides, + + pub var offset: c_int = 0; + }; + + pub fn new(alloc: Allocator) Allocator.Error!*ConfigOverrides { + const self = gobject.ext.newInstance(Self, .{}); + errdefer self.unref(); + + const priv: *Private = self.private(); + try priv.config_overrides.init(alloc); + + return self; + } + + pub fn get(self: *ConfigOverrides) *configpkg.ConfigOverrides { + const priv: *Private = self.private(); + return &priv.config_overrides; + } + + fn finalize(self: *ConfigOverrides) callconv(.c) void { + const priv: *Private = self.private(); + priv.config_overrides.deinit(); + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + }; +}; + +test "GhosttyConfigOverrides" { + const testing = std.testing; + const alloc = testing.allocator; + + const config_overrides: *ConfigOverrides = try .new(alloc); + defer config_overrides.unref(); + + const co = config_overrides.get(); + + try testing.expect(co.isSet(.@"font-size") == false); + try co.set(.@"font-size", 24.0); + try testing.expect(co.isSet(.@"font-size") == true); + try testing.expectApproxEqAbs(24.0, co.get(.@"font-size"), 0.01); + + try testing.expect(co.isSet(.@"working-directory") == false); + try co.parseCLI("--working-directory=/home/ghostty"); + try testing.expect(co.isSet(.@"working-directory") == true); + try testing.expectEqualStrings("/home/ghostty", co.get(.@"working-directory").?); +} diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 0ff7e6044..0d47db958 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -16,6 +16,7 @@ const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const Surface = @import("surface.zig").Surface; const SurfaceScrolledWindow = @import("surface_scrolled_window.zig").SurfaceScrolledWindow; +const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const log = std.log.scoped(.gtk_ghostty_split_tree); @@ -208,11 +209,12 @@ pub const SplitTree = extern struct { self: *Self, direction: Surface.Tree.Split.Direction, parent_: ?*Surface, + config_overrides: ?*ConfigOverrides, ) Allocator.Error!void { const alloc = Application.default().allocator(); // Create our new surface. - const surface: *Surface = .new(); + const surface: *Surface = .new(config_overrides); defer surface.unref(); _ = surface.refSink(); @@ -638,6 +640,7 @@ pub const SplitTree = extern struct { self.newSplit( direction, self.getActiveSurface(), + null, ) catch |err| { log.warn("new split failed error={}", .{err}); }; diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index b76ddba7e..423b5b7e8 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -10,6 +10,7 @@ const gtk = @import("gtk"); const apprt = @import("../../../apprt.zig"); const build_config = @import("../../../build_config.zig"); +const configpkg = @import("../../../config.zig"); const datastruct = @import("../../../datastruct/main.zig"); const font = @import("../../../font/main.zig"); const input = @import("../../../input.zig"); @@ -25,6 +26,7 @@ const ApprtSurface = @import("../Surface.zig"); const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; const Config = @import("config.zig").Config; +const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; const SearchOverlay = @import("search_overlay.zig").SearchOverlay; const KeyStateOverlay = @import("key_state_overlay.zig").KeyStateOverlay; @@ -89,6 +91,18 @@ pub const Surface = extern struct { ); }; + pub const @"config-overrides" = struct { + pub const name = "config-overrides"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*ConfigOverrides, + .{ + .accessor = C.privateObjFieldAccessor("config_overrides"), + }, + ); + }; + pub const @"child-exited" = struct { pub const name = "child-exited"; const impl = gobject.ext.defineProperty( @@ -551,6 +565,9 @@ pub const Surface = extern struct { /// The configuration that this surface is using. config: ?*Config = null, + /// Any configuration overrides that might apply to this surface. + config_overrides: ?*ConfigOverrides = null, + /// The default size for a window that embeds this surface. default_size: ?*Size = null, @@ -707,8 +724,10 @@ pub const Surface = extern struct { pub var offset: c_int = 0; }; - pub fn new() *Self { - return gobject.ext.newInstance(Self, .{}); + pub fn new(config_overrides: ?*ConfigOverrides) *Self { + return gobject.ext.newInstance(Self, .{ + .@"config-overrides" = config_overrides, + }); } pub fn core(self: *Self) ?*CoreSurface { @@ -1798,6 +1817,11 @@ pub const Surface = extern struct { priv.config = null; } + if (priv.config_overrides) |v| { + v.unref(); + priv.config_overrides = null; + } + if (priv.vadj_signal_group) |group| { group.setTarget(null); group.as(gobject.Object).unref(); @@ -2176,6 +2200,12 @@ pub const Surface = extern struct { self.private().search_overlay.setSearchSelected(selected); } + pub fn getConfigOverrides(self: *Self) ?*const configpkg.ConfigOverrides { + const priv: *Private = self.private(); + const config_overrides = priv.config_overrides orelse return null; + return config_overrides.get(); + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, @@ -3578,6 +3608,7 @@ pub const Surface = extern struct { gobject.ext.registerProperties(class, &.{ properties.@"bell-ringing".impl, properties.config.impl, + properties.@"config-overrides".impl, properties.@"child-exited".impl, properties.@"default-size".impl, properties.@"error".impl, diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index 24caa4990..d45513e08 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -11,6 +11,7 @@ const ext = @import("../ext.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; +const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const Application = @import("application.zig").Application; const SplitTree = @import("split_tree.zig").SplitTree; const Surface = @import("surface.zig").Surface; @@ -186,22 +187,24 @@ pub const Tab = extern struct { } } - fn init(self: *Self, _: *Class) callconv(.c) void { - gtk.Widget.initTemplate(self.as(gtk.Widget)); + pub fn new(config: ?*Config, config_overrides: ?*ConfigOverrides) *Self { + const tab = gobject.ext.newInstance(Tab, .{}); - // Init our actions - self.initActionMap(); + const priv: *Private = tab.private(); + + if (config) |c| priv.config = c.ref(); // If our configuration is null then we get the configuration // from the application. - const priv = self.private(); if (priv.config == null) { const app = Application.default(); priv.config = app.getConfig(); } + tab.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec); + // Create our initial surface in the split tree. - priv.split_tree.newSplit(.right, null) catch |err| switch (err) { + priv.split_tree.newSplit(.right, null, config_overrides) catch |err| switch (err) { error.OutOfMemory => { // TODO: We should make our "no surfaces" state more aesthetically // pleasing and show something like an "Oops, something went wrong" @@ -209,6 +212,15 @@ pub const Tab = extern struct { @panic("oom"); }, }; + + return tab; + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Init our actions + self.initActionMap(); } fn initActionMap(self: *Self) void { diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index a79945991..a71fea111 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -21,6 +21,7 @@ const gresource = @import("../build/gresource.zig"); const winprotopkg = @import("../winproto.zig"); const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; +const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const SplitTree = @import("split_tree.zig").SplitTree; @@ -368,21 +369,20 @@ pub const Window = extern struct { /// at the position dictated by the `window-new-tab-position` config. /// The new tab will be selected. pub fn newTab(self: *Self, parent_: ?*CoreSurface) void { - _ = self.newTabPage(parent_, .tab); + _ = self.newTabPage(parent_, .tab, null); } - pub fn newTabForWindow(self: *Self, parent_: ?*CoreSurface) void { - _ = self.newTabPage(parent_, .window); + pub fn newTabForWindow(self: *Self, parent_: ?*CoreSurface, config_overrides: ?*ConfigOverrides) void { + _ = self.newTabPage(parent_, .window, config_overrides); } - fn newTabPage(self: *Self, parent_: ?*CoreSurface, context: apprt.surface.NewSurfaceContext) *adw.TabPage { - const priv = self.private(); + fn newTabPage(self: *Self, parent_: ?*CoreSurface, context: apprt.surface.NewSurfaceContext, config_overrides: ?*ConfigOverrides) *adw.TabPage { + const priv: *Private = self.private(); const tab_view = priv.tab_view; // Create our new tab object - const tab = gobject.ext.newInstance(Tab, .{ - .config = priv.config, - }); + const tab = Tab.new(priv.config, config_overrides); + if (parent_) |p| { // For a new window's first tab, inherit the parent's initial size hints. if (context == .window) { @@ -1253,7 +1253,7 @@ pub const Window = extern struct { _: *adw.TabOverview, self: *Self, ) callconv(.c) *adw.TabPage { - return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null, .tab); + return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null, .tab, null); } fn tabOverviewOpen( diff --git a/src/apprt/gtk/ipc/new_window.zig b/src/apprt/gtk/ipc/new_window.zig index 19c46e3aa..02fed3229 100644 --- a/src/apprt/gtk/ipc/new_window.zig +++ b/src/apprt/gtk/ipc/new_window.zig @@ -18,7 +18,7 @@ const DBus = @import("DBus.zig"); // `ghostty +new-window -e echo hello` would be equivalent to the following command (on a release build): // // ``` -// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window-command '[<@as ["echo" "hello"]>]' [] +// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window-command '[<@as ["-e" "echo" "hello"]>]' [] // ``` pub fn newWindow(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewWindow) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!bool { var dbus = try DBus.init( @@ -32,10 +32,10 @@ pub fn newWindow(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Ac defer dbus.deinit(alloc); if (value.arguments) |arguments| { - // If `-e` was specified on the command line, the first - // parameter is an array of strings that contain the arguments - // that came after `-e`, which will be interpreted as a command - // to run. + // If any arguments were specified on the command line, the first + // parameter is an array of strings that contain the arguments. They + // will be sent to the main Ghostty instance and interpreted as CLI + // arguments. const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); diff --git a/src/cli/new_window.zig b/src/cli/new_window.zig index f3f4740d1..98a93beb2 100644 --- a/src/cli/new_window.zig +++ b/src/cli/new_window.zig @@ -5,6 +5,8 @@ const Action = @import("../cli.zig").ghostty.Action; const apprt = @import("../apprt.zig"); const args = @import("args.zig"); const diagnostics = @import("diagnostics.zig"); +const lib = @import("../lib/main.zig"); +const homedir = @import("../os/homedir.zig"); pub const Options = struct { /// This is set by the CLI parser for deinit. @@ -13,28 +15,38 @@ pub const Options = struct { /// If set, open up a new window in a custom instance of Ghostty. class: ?[:0]const u8 = null, - /// If `-e` is found in the arguments, this will contain all of the - /// arguments to pass to Ghostty as the command. + /// All of the arguments after `+new-window`. They will be sent to Ghosttty + /// for processing. _arguments: ?[][:0]const u8 = null, /// Enable arg parsing diagnostics so that we don't get an error if /// there is a "normal" config setting on the cli. _diagnostics: diagnostics.DiagnosticList = .{}, - /// Manual parse hook, used to deal with `-e` - pub fn parseManuallyHook(self: *Options, alloc: Allocator, arg: []const u8, iter: anytype) Allocator.Error!bool { - // If it's not `-e` continue with the standard argument parsning. - if (!std.mem.eql(u8, arg, "-e")) return true; - + /// Manual parse hook, collect all of the arguments after `+new-window`. + pub fn parseManuallyHook(self: *Options, alloc: Allocator, arg: []const u8, iter: anytype) (error{InvalidValue} || homedir.ExpandError || std.fs.Dir.RealPathAllocError || Allocator.Error)!bool { var arguments: std.ArrayList([:0]const u8) = .empty; errdefer { for (arguments.items) |argument| alloc.free(argument); arguments.deinit(alloc); } - // Otherwise gather up the rest of the arguments to use as the command. + var e_seen: bool = std.mem.eql(u8, arg, "-e"); + // Include the argument that triggered the manual parse hook. + if (try self.checkArg(alloc, arg)) |a| try arguments.append(alloc, a); + + // Gather up the rest of the arguments to use as the command. while (iter.next()) |param| { - try arguments.append(alloc, try alloc.dupeZ(u8, param)); + if (e_seen) { + try arguments.append(alloc, try alloc.dupeZ(u8, param)); + continue; + } + if (std.mem.eql(u8, param, "-e")) { + e_seen = true; + try arguments.append(alloc, try alloc.dupeZ(u8, param)); + continue; + } + if (try self.checkArg(alloc, param)) |a| try arguments.append(alloc, a); } self._arguments = try arguments.toOwnedSlice(alloc); @@ -42,6 +54,27 @@ pub const Options = struct { return false; } + fn checkArg(self: *Options, alloc: Allocator, arg: []const u8) (error{InvalidValue} || homedir.ExpandError || std.fs.Dir.RealPathAllocError || Allocator.Error)!?[:0]const u8 { + if (lib.cutPrefix(u8, arg, "--class=")) |rest| { + self.class = try alloc.dupeZ(u8, std.mem.trim(u8, rest, &std.ascii.whitespace)); + return null; + } + + if (lib.cutPrefix(u8, arg, "--working-directory=")) |rest| { + const stripped = std.mem.trim(u8, rest, &std.ascii.whitespace); + if (std.mem.eql(u8, stripped, "home")) return error.InvalidValue; + if (std.mem.eql(u8, stripped, "inherit")) return error.InvalidValue; + const cwd: std.fs.Dir = std.fs.cwd(); + var expandhome_buf: [std.fs.max_path_bytes]u8 = undefined; + const expanded = try homedir.expandHome(stripped, &expandhome_buf); + var realpath_buf: [std.fs.max_path_bytes]u8 = undefined; + const realpath = try cwd.realpath(expanded, &realpath_buf); + return try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{realpath}, 0); + } + + return try alloc.dupeZ(u8, arg); + } + pub fn deinit(self: *Options) void { if (self._arena) |arena| arena.deinit(); self.* = undefined; @@ -63,11 +96,21 @@ pub const Options = struct { /// and contact a running Ghostty instance that was configured with the same /// `class` as was given on the command line. /// -/// If the `-e` flag is included on the command line, any arguments that follow -/// will be sent to the running Ghostty instance and used as the command to run -/// in the new window rather than the default. If `-e` is not specified, Ghostty -/// will use the default command (either specified with `command` in your config -/// or your default shell as configured on your system). +/// All of the arguments after the `+new-window` argument (except for the +/// `--class` flag) will be sent to the remote Ghostty instance and will be +/// parsed as command line flags. These flags will override certain settings +/// when creating the first surface in the new window. Currently, only +/// `--working-directory` and `--command` are supported. `-e` will also work +/// as an alias for `--command`, except that if `-e` is found on the command +/// line all following arguments will become part of the command and no more +/// arguments will be parsed for configuration settings. +/// +/// If `--working-directory` is found on the command line and is a relative +/// path (i.e. doesn't start with `/`) it will be resolved to an absolute path +/// relative to the current working directory that the `ghostty +new-window` +/// command is run from. The special values `home` and `inherit` that are +/// available as "normal" CLI flags or configuration entries do not work when +/// used from the `+new-window` CLI action. /// /// GTK uses an application ID to identify instances of applications. If Ghostty /// is compiled with release optimizations, the default application ID will be diff --git a/src/config.zig b/src/config.zig index 0bf61a47f..d559ab171 100644 --- a/src/config.zig +++ b/src/config.zig @@ -3,6 +3,7 @@ const builtin = @import("builtin"); const file_load = @import("config/file_load.zig"); const formatter = @import("config/formatter.zig"); pub const Config = @import("config/Config.zig"); +pub const ConfigOverrides = @import("config/ConfigOverrides.zig"); pub const conditional = @import("config/conditional.zig"); pub const io = @import("config/io.zig"); pub const string = @import("config/string.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index 7020a2b57..ce891561c 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -29,7 +29,8 @@ const file_load = @import("file_load.zig"); const formatterpkg = @import("formatter.zig"); const themepkg = @import("theme.zig"); const url = @import("url.zig"); -const Key = @import("key.zig").Key; +pub const Key = @import("key.zig").Key; +pub const Type = @import("key.zig").Type; const MetricModifier = fontpkg.Metrics.Modifier; const help_strings = @import("help_strings"); pub const Command = @import("command.zig").Command; @@ -95,6 +96,23 @@ pub const compatibility = std.StaticStringMap( .{ "macos-dock-drop-behavior", compatMacOSDockDropBehavior }, }); +pub fn get(self: *const Config, comptime key: Key) Type(key) { + return @field(self, @tagName(key)); +} + +pub fn set(self: *Config, comptime key: Key, value: Type(key)) Allocator.Error!void { + const alloc = self.arenaAlloc(); + @field(self.*, @tagName(key)) = try cloneValue(alloc, Type(key), value); +} + +test "set/get" { + var config: Config = try .default(std.testing.allocator); + defer config.deinit(); + try std.testing.expect(config.get(.language) == null); + try config.set(.language, "en_US.UTF-8"); + try std.testing.expectEqualStrings("en_US.UTF-8", config.get(.language).?); +} + /// Set Ghostty's graphical user interface language to a language other than the /// system default language. For example: /// @@ -4757,8 +4775,8 @@ fn compatCursorInvertFgBg( // Realistically, these fields were mutually exclusive so anyone // relying on that behavior should just upgrade to the new // cursor-color/cursor-text fields. - const set = cli.args.parseBool(value_ orelse "t") catch return false; - if (set) { + const isset = cli.args.parseBool(value_ orelse "t") catch return false; + if (isset) { self.@"cursor-color" = .@"cell-foreground"; self.@"cursor-text" = .@"cell-background"; } @@ -4775,8 +4793,8 @@ fn compatSelectionInvertFgBg( _ = alloc; assert(std.mem.eql(u8, key, "selection-invert-fg-bg")); - const set = cli.args.parseBool(value_ orelse "t") catch return false; - if (set) { + const isset = cli.args.parseBool(value_ orelse "t") catch return false; + if (isset) { self.@"selection-foreground" = .@"cell-background"; self.@"selection-background" = .@"cell-foreground"; } @@ -4793,8 +4811,8 @@ fn compatBoldIsBright( _ = alloc; assert(std.mem.eql(u8, key, "bold-is-bright")); - const set = cli.args.parseBool(value_ orelse "t") catch return false; - if (set) { + const isset = cli.args.parseBool(value_ orelse "t") catch return false; + if (isset) { self.@"bold-color" = .bright; } @@ -7261,9 +7279,9 @@ pub const Keybinds = struct { defer arena.deinit(); const alloc = arena.allocator(); - var set: Keybinds = .{}; - try set.parseCLI(alloc, "shift+a=copy_to_clipboard"); - try set.parseCLI(alloc, "shift+a=csi:hello"); + var keyset: Keybinds = .{}; + try keyset.parseCLI(alloc, "shift+a=copy_to_clipboard"); + try keyset.parseCLI(alloc, "shift+a=csi:hello"); } test "formatConfig single" { diff --git a/src/config/ConfigOverrides.zig b/src/config/ConfigOverrides.zig new file mode 100644 index 000000000..325359aef --- /dev/null +++ b/src/config/ConfigOverrides.zig @@ -0,0 +1,95 @@ +//! Wrapper for a Config object that keeps track of which settings have been +//! changed. Settings will be marked as set even if they are set to whatever the +//! default value is for that setting. This allows overrides of a setting from +//! a non-default value to the default value. To remove an override it must be +//! explicitly removed from the set that keeps track of what config entries have +//! been changed. + +const ConfigOverrides = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const configpkg = @import("../config.zig"); +const args = @import("../cli/args.zig"); +const Config = configpkg.Config; +const Key = Config.Key; +const Type = Config.Type; + +const log = std.log.scoped(.config_overrides); + +/// Used to keep track of which settings have been overridden. +isset: std.EnumSet(configpkg.Config.Key), + +/// Storage for the overriding settings. +config: configpkg.Config, + +/// Create a new object that has no config settings overridden. +pub fn init(self: *ConfigOverrides, alloc: Allocator) Allocator.Error!void { + self.* = .{ + .isset = .initEmpty(), + .config = try .default(alloc), + }; +} + +/// Has a config setting been overridden? +pub fn isSet(self: *const ConfigOverrides, comptime key: Key) bool { + return self.isset.contains(key); +} + +/// Set a configuration entry and mark it as having been overridden. +pub fn set(self: *ConfigOverrides, comptime key: Key, value: Type(key)) Allocator.Error!void { + try self.config.set(key, value); + self.isset.insert(key); +} + +/// Mark a configuration entry as having not been overridden. +pub fn unset(self: *ConfigOverrides, comptime key: Key) void { + self.isset.remove(key); +} + +/// Get the value of a configuration entry. +pub fn get(self: *const ConfigOverrides, comptime key: Key) Type(key) { + return self.config.get(key); +} + +/// Parse a string that contains a CLI flag. +pub fn parseCLI(self: *ConfigOverrides, str: []const u8) !void { + const k: []const u8, const v: ?[]const u8 = kv: { + if (!std.mem.startsWith(u8, str, "--")) return; + if (std.mem.indexOfScalarPos(u8, str, 2, '=')) |pos| { + break :kv .{ + std.mem.trim(u8, str[2..pos], &std.ascii.whitespace), + std.mem.trim(u8, str[pos + 1 ..], &std.ascii.whitespace), + }; + } + break :kv .{ std.mem.trim(u8, str[2..], &std.ascii.whitespace), null }; + }; + + const key = std.meta.stringToEnum(Key, k) orelse return; + try args.parseIntoField(Config, self.config.arenaAlloc(), &self.config, k, v); + self.isset.insert(key); +} + +pub fn deinit(self: *ConfigOverrides) callconv(.c) void { + self.config.deinit(); +} + +test "ConfigOverrides" { + const testing = std.testing; + const alloc = testing.allocator; + + var config_overrides: ConfigOverrides = undefined; + try config_overrides.init(alloc); + defer config_overrides.deinit(); + + try testing.expect(config_overrides.isSet(.@"font-size") == false); + try config_overrides.set(.@"font-size", 24.0); + try testing.expect(config_overrides.isSet(.@"font-size") == true); + try testing.expectApproxEqAbs(24.0, config_overrides.get(.@"font-size"), 0.01); + + try testing.expect(config_overrides.isSet(.@"working-directory") == false); + try config_overrides.parseCLI("--working-directory=/home/ghostty"); + try testing.expect(config_overrides.isSet(.@"working-directory") == true); + try testing.expectEqualStrings("/home/ghostty", config_overrides.get(.@"working-directory").?); +} diff --git a/src/config/c_get.zig b/src/config/c_get.zig index dcfdc6716..a3a45d24c 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -4,7 +4,7 @@ const key = @import("key.zig"); const Config = @import("Config.zig"); const Color = Config.Color; const Key = key.Key; -const Value = key.Value; +const Type = key.Type; /// Get a value from the config by key into the given pointer. This is /// specifically for C-compatible APIs. If you're using Zig, just access @@ -17,7 +17,7 @@ pub fn get(config: *const Config, k: Key, ptr_raw: *anyopaque) bool { @setEvalBranchQuota(10_000); switch (k) { inline else => |tag| { - const value = fieldByKey(config, tag); + const value = config.get(tag); return getValue(ptr_raw, value); }, } @@ -102,22 +102,6 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool { return true; } -/// Get a value from the config by key. -fn fieldByKey(self: *const Config, comptime k: Key) Value(k) { - const field = comptime field: { - const fields = std.meta.fields(Config); - for (fields) |field| { - if (@field(Key, field.name) == k) { - break :field field; - } - } - - unreachable; - }; - - return @field(self, field.name); -} - test "c_get: u8" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/config/key.zig b/src/config/key.zig index 5709e2074..6c083673a 100644 --- a/src/config/key.zig +++ b/src/config/key.zig @@ -32,7 +32,7 @@ pub const Key = key: { }; /// Returns the value type for a key -pub fn Value(comptime key: Key) type { +pub fn Type(comptime key: Key) type { const field = comptime field: { @setEvalBranchQuota(100_000); @@ -52,6 +52,6 @@ pub fn Value(comptime key: Key) type { test "Value" { const testing = std.testing; - try testing.expectEqual(Config.RepeatableString, Value(.@"font-family")); - try testing.expectEqual(?bool, Value(.@"cursor-style-blink")); + try testing.expectEqual(Config.RepeatableString, Type(.@"font-family")); + try testing.expectEqual(?bool, Type(.@"cursor-style-blink")); } diff --git a/src/lib/main.zig b/src/lib/main.zig index e4a67454e..89c6f6c47 100644 --- a/src/lib/main.zig +++ b/src/lib/main.zig @@ -10,6 +10,7 @@ pub const String = types.String; pub const Struct = @import("struct.zig").Struct; pub const Target = @import("target.zig").Target; pub const TaggedUnion = unionpkg.TaggedUnion; +pub const cutPrefix = @import("string.zig").cutPrefix; test { std.testing.refAllDecls(@This()); diff --git a/src/lib/string.zig b/src/lib/string.zig new file mode 100644 index 000000000..795823c25 --- /dev/null +++ b/src/lib/string.zig @@ -0,0 +1,15 @@ +const std = @import("std"); + +// This is a copy of std.mem.cutPrefix from 0.16. Once Ghostty has been ported +// to 0.16 this can be removed. + +/// If slice starts with prefix, returns the rest of slice starting at +/// prefix.len. +pub fn cutPrefix(comptime T: type, slice: []const T, prefix: []const T) ?[]const T { + return if (std.mem.startsWith(T, slice, prefix)) slice[prefix.len..] else null; +} + +test cutPrefix { + try std.testing.expectEqualStrings("foo", cutPrefix(u8, "--example=foo", "--example=").?); + try std.testing.expectEqual(null, cutPrefix(u8, "--example=foo", "-example=")); +}