gtk: +new-window now respects --working-directory and -e

Fixes: #8862
Fixes: #10716

This adds the machinery to pass configuration settings received over
DBus down to the GObject Surface so that that configuration information
can be used to override some settings from the current "live" config
when creating a new window. Currently it's only possible to override
`--working-directory` and `--command`. `-e` on the `ghostty +new-window`
CLI works as well.

Adding more overridable settings is possible, but being able to fully
override any possible setting would better be served with a major
revamp of how Ghostty handles configs, which I is way out of scope at
the moment.
This commit is contained in:
Jeffrey C. Ollie
2026-02-17 20:19:33 -06:00
parent c3febabd28
commit 6961c2265e
17 changed files with 471 additions and 87 deletions

View File

@@ -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,

View File

@@ -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();
}

View File

@@ -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;
}
_ = self.core().mailbox.push(.{
.new_window = .{},
}, .{ .forever = {} });
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 });
}
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;
}

View File

@@ -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").?);
}

View File

@@ -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});
};

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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();

View File

@@ -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

View File

@@ -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");

View File

@@ -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" {

View File

@@ -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").?);
}

View File

@@ -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;

View File

@@ -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"));
}

View File

@@ -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());

15
src/lib/string.zig Normal file
View File

@@ -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="));
}