mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-09-05 19:08:17 +00:00

This removes `launched-from` entirely and moves our `gtk-single-instance` detection logic to assume true unless we detect CLI instead of assume false unless we detect desktop/dbus/systemd. The "assume true" scenario for single instance is desirable because detecting a CLI instance is much more reliable. Removing `launched-from` fixes an issue where we had a difficult-to-understand relationship between `launched-from`, `gtk-single-instance`, and `initial-window`. Now, only `gtk-single-instance` has some hueristic logic. And `initial-window` ALWAYS sends a GTK activation signal regardless of single instance or not. As a result, we need to be explicit in our systemd, dbus, desktop files about what we want Ghostty to do, but everything works as you'd mostly expect. Now, if you put plain old `ghostty` in your terminal, you get a new Ghostty instance. If you put it anywhere else, you get a GTK single instance activation call (either creates a first instance or opens a new window in the existing instance). Works for launchers and so on.
2506 lines
86 KiB
Zig
2506 lines
86 KiB
Zig
const std = @import("std");
|
|
const assert = std.debug.assert;
|
|
const Allocator = std.mem.Allocator;
|
|
const builtin = @import("builtin");
|
|
const adw = @import("adw");
|
|
const gdk = @import("gdk");
|
|
const gio = @import("gio");
|
|
const glib = @import("glib");
|
|
const gobject = @import("gobject");
|
|
const gtk = @import("gtk");
|
|
|
|
const build_config = @import("../../../build_config.zig");
|
|
const i18n = @import("../../../os/main.zig").i18n;
|
|
const apprt = @import("../../../apprt.zig");
|
|
const cgroup = @import("../cgroup.zig");
|
|
const CoreApp = @import("../../../App.zig");
|
|
const configpkg = @import("../../../config.zig");
|
|
const input = @import("../../../input.zig");
|
|
const internal_os = @import("../../../os/main.zig");
|
|
const systemd = @import("../../../os/systemd.zig");
|
|
const terminal = @import("../../../terminal/main.zig");
|
|
const xev = @import("../../../global.zig").xev;
|
|
const Binding = @import("../../../input.zig").Binding;
|
|
const CoreConfig = configpkg.Config;
|
|
const CoreSurface = @import("../../../Surface.zig");
|
|
|
|
const ext = @import("../ext.zig");
|
|
const key = @import("../key.zig");
|
|
const adw_version = @import("../adw_version.zig");
|
|
const gtk_version = @import("../gtk_version.zig");
|
|
const winprotopkg = @import("../winproto.zig");
|
|
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 Surface = @import("surface.zig").Surface;
|
|
const SplitTree = @import("split_tree.zig").SplitTree;
|
|
const Window = @import("window.zig").Window;
|
|
const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
|
|
const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog;
|
|
const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts;
|
|
|
|
const log = std.log.scoped(.gtk_ghostty_application);
|
|
|
|
/// The primary entrypoint for the Ghostty GTK application.
|
|
///
|
|
/// This requires a `ghostty.App` and `ghostty.Config` and takes
|
|
/// care of the rest. Call `run` to run the application to completion.
|
|
pub const Application = extern struct {
|
|
/// This type creates a new GObject class. Since the Application is
|
|
/// the primary entrypoint I'm going to use this as a place to document
|
|
/// how this all works and where you can find resources for it, but
|
|
/// this applies to any other GObject class within this apprt.
|
|
///
|
|
/// The various fields (parent_instance) and constants (Parent,
|
|
/// getGObjectType, etc.) are mandatory "interfaces" for zig-gobject
|
|
/// to create a GObject class.
|
|
///
|
|
/// I found these to be the best resources:
|
|
///
|
|
/// * https://github.com/ianprime0509/zig-gobject/blob/d7f1edaf50193d49b56c60568dfaa9f23195565b/extensions/gobject2.zig
|
|
/// * https://github.com/ianprime0509/zig-gobject/blob/d7f1edaf50193d49b56c60568dfaa9f23195565b/example/src/custom_class.zig
|
|
///
|
|
const Self = @This();
|
|
|
|
parent_instance: Parent,
|
|
pub const Parent = adw.Application;
|
|
pub const getGObjectType = gobject.ext.defineClass(Self, .{
|
|
.name = "GhosttyApplication",
|
|
.classInit = &Class.init,
|
|
.parent_class = &Class.parent,
|
|
.private = .{ .Type = Private, .offset = &Private.offset },
|
|
});
|
|
|
|
pub const properties = struct {
|
|
pub const config = struct {
|
|
pub const name = "config";
|
|
const impl = gobject.ext.defineProperty(
|
|
"config",
|
|
Self,
|
|
?*Config,
|
|
.{
|
|
.accessor = gobject.ext.typedAccessor(
|
|
Self,
|
|
?*Config,
|
|
.{
|
|
.getter = Self.getConfig,
|
|
.getter_transfer = .full,
|
|
},
|
|
),
|
|
},
|
|
);
|
|
};
|
|
};
|
|
|
|
const Private = struct {
|
|
/// The apprt App. This is annoying that we need this it'd be
|
|
/// nicer to just make THIS the apprt app but the current libghostty
|
|
/// API doesn't allow that.
|
|
rt_app: *ApprtApp,
|
|
|
|
/// The libghostty App instance.
|
|
core_app: *CoreApp,
|
|
|
|
/// The configuration for the application.
|
|
config: *Config,
|
|
|
|
/// State and logic for the underlying windowing protocol.
|
|
winproto: winprotopkg.App,
|
|
|
|
/// The global shortcut logic.
|
|
global_shortcuts: *GlobalShortcuts,
|
|
|
|
/// The base path of the transient cgroup used to put all surfaces
|
|
/// into their own cgroup. This is only set if cgroups are enabled
|
|
/// and initialization was successful.
|
|
transient_cgroup_base: ?[]const u8 = null,
|
|
|
|
/// This is set to true so long as we request a window exactly
|
|
/// once. This prevents quitting the app before we've shown one
|
|
/// window.
|
|
requested_window: bool = false,
|
|
|
|
/// This is set to false internally when the event loop
|
|
/// should exit and the application should quit. This must
|
|
/// only be set by the main loop thread.
|
|
running: bool = false,
|
|
|
|
/// The timer used to quit the application after the last window is
|
|
/// closed. Even if there is no quit delay set, this is the state
|
|
/// used to determine to close the app.
|
|
quit_timer: union(enum) {
|
|
off,
|
|
active: c_uint,
|
|
expired,
|
|
} = .off,
|
|
|
|
/// If non-null, we're currently showing a config errors dialog.
|
|
/// This is a WeakRef because the dialog can close on its own
|
|
/// outside of our own lifecycle and that's okay.
|
|
config_errors_dialog: WeakRef(ConfigErrorsDialog) = .empty,
|
|
|
|
/// glib source for our signal handler.
|
|
signal_source: ?c_uint = null,
|
|
|
|
/// CSS Provider for any styles based on Ghostty configuration values.
|
|
css_provider: *gtk.CssProvider,
|
|
|
|
/// Providers for loading custom stylesheets defined by user
|
|
custom_css_providers: std.ArrayListUnmanaged(*gtk.CssProvider) = .empty,
|
|
|
|
pub var offset: c_int = 0;
|
|
};
|
|
|
|
/// Get this application as the default, allowing access to its
|
|
/// properties globally.
|
|
///
|
|
/// This asserts that there is a default application and that the
|
|
/// default application is a GhosttyApplication. The program would have
|
|
/// to be in a very bad state for this to be violated.
|
|
pub fn default() *Self {
|
|
const app = gio.Application.getDefault().?;
|
|
return gobject.ext.cast(Self, app).?;
|
|
}
|
|
|
|
/// Creates a new Application instance.
|
|
///
|
|
/// This does a lot more work than a typical class instantiation,
|
|
/// because we expect that this is the main program entrypoint.
|
|
///
|
|
/// The only failure mode of initializing the application is early OOM.
|
|
/// Early OOM can't be recovered from. Every other error is mapped to
|
|
/// some degraded state where we can at least show a window with an error.
|
|
pub fn new(
|
|
rt_app: *ApprtApp,
|
|
core_app: *CoreApp,
|
|
) Allocator.Error!*Self {
|
|
const alloc = core_app.alloc;
|
|
|
|
// Log our GTK versions
|
|
gtk_version.logVersion();
|
|
adw_version.logVersion();
|
|
|
|
// Set gettext global domain to be our app so that our unqualified
|
|
// translations map to our translations.
|
|
internal_os.i18n.initGlobalDomain() catch |err| {
|
|
// Failures shuldn't stop application startup. Our app may
|
|
// not translate correctly but it should still work. In the
|
|
// future we may want to add this to the GUI to show.
|
|
log.warn("i18n initialization failed error={}", .{err});
|
|
};
|
|
|
|
// Load our configuration.
|
|
var config = CoreConfig.load(alloc) catch |err| err: {
|
|
// If we fail to load the configuration, then we should log
|
|
// the error in the diagnostics so it can be shown to the user.
|
|
// We can still load a default which only fails for OOM, allowing
|
|
// us to startup.
|
|
var def: CoreConfig = try .default(alloc);
|
|
errdefer def.deinit();
|
|
try def.addDiagnosticFmt(
|
|
"error loading user configuration: {}",
|
|
.{err},
|
|
);
|
|
|
|
break :err def;
|
|
};
|
|
defer config.deinit();
|
|
|
|
// Setup our GTK init env vars
|
|
setGtkEnv(&config) catch |err| switch (err) {
|
|
error.NoSpaceLeft => {
|
|
// If we fail to set GTK environment variables then we still
|
|
// try to start the application...
|
|
log.warn(
|
|
"error setting GTK environment variables err={}",
|
|
.{err},
|
|
);
|
|
},
|
|
};
|
|
adw.init();
|
|
|
|
const single_instance = switch (config.@"gtk-single-instance") {
|
|
.true => true,
|
|
.false => false,
|
|
// This should have been resolved to true/false during config loading.
|
|
.detect => unreachable,
|
|
};
|
|
|
|
// Setup the flags for our application.
|
|
const app_flags: gio.ApplicationFlags = app_flags: {
|
|
var flags: gio.ApplicationFlags = .flags_default_flags;
|
|
if (!single_instance) flags.non_unique = true;
|
|
break :app_flags flags;
|
|
};
|
|
|
|
// Our app ID determines uniqueness and maps to our desktop file.
|
|
// We append "-debug" to the ID if we're in debug mode so that we
|
|
// can develop Ghostty in Ghostty.
|
|
const app_id: [:0]const u8 = app_id: {
|
|
if (config.class) |class| {
|
|
if (gio.Application.idIsValid(class) != 0) {
|
|
break :app_id class;
|
|
} else {
|
|
log.warn("invalid 'class' in config, ignoring", .{});
|
|
}
|
|
}
|
|
|
|
break :app_id ApprtApp.application_id;
|
|
};
|
|
|
|
const display: *gdk.Display = gdk.Display.getDefault() orelse {
|
|
// I'm unsure of any scenario where this happens. Because we don't
|
|
// want to litter null checks everywhere, we just exit here.
|
|
log.warn("gdk display is null, exiting", .{});
|
|
std.posix.exit(1);
|
|
};
|
|
|
|
// Setup our windowing protocol logic
|
|
var wp: winprotopkg.App = winprotopkg.App.init(
|
|
alloc,
|
|
display,
|
|
app_id,
|
|
&config,
|
|
) catch |err| wp: {
|
|
// If we fail to detect or setup the windowing protocol
|
|
// specifies, we fallback to a noop implementation so we can
|
|
// still launch.
|
|
log.warn("error initializing windowing protocol err={}", .{err});
|
|
break :wp .{ .none = .{} };
|
|
};
|
|
errdefer wp.deinit(alloc);
|
|
log.debug("windowing protocol={s}", .{@tagName(wp)});
|
|
|
|
// Create our GTK Application which encapsulates our process.
|
|
log.debug("creating GTK application id={s} single-instance={}", .{
|
|
app_id,
|
|
single_instance,
|
|
});
|
|
|
|
// Wrap our configuration in a GObject.
|
|
const config_obj: *Config = try .new(alloc, &config);
|
|
errdefer config_obj.unref();
|
|
|
|
// Internally, GTK ensures that only one instance of this provider
|
|
// exists in the provider list for the display.
|
|
const css_provider = gtk.CssProvider.new();
|
|
gtk.StyleContext.addProviderForDisplay(
|
|
display,
|
|
css_provider.as(gtk.StyleProvider),
|
|
gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 3,
|
|
);
|
|
errdefer css_provider.unref();
|
|
|
|
// Initialize the app.
|
|
const self = gobject.ext.newInstance(Self, .{
|
|
.application_id = app_id.ptr,
|
|
.flags = app_flags,
|
|
|
|
// Force the resource path to a known value so it doesn't depend
|
|
// on the app id (which changes between debug/release and can be
|
|
// user-configured) and force it to load in compiled resources.
|
|
.resource_base_path = "/com/mitchellh/ghostty",
|
|
});
|
|
|
|
// Setup our private state. More setup is done in the init
|
|
// callback that GObject calls, but we can't pass this data through
|
|
// to there (and we don't need it there directly) so this is here.
|
|
const priv = self.private();
|
|
priv.* = .{
|
|
.rt_app = rt_app,
|
|
.core_app = core_app,
|
|
.config = config_obj,
|
|
.winproto = wp,
|
|
.css_provider = css_provider,
|
|
.custom_css_providers = .empty,
|
|
.global_shortcuts = gobject.ext.newInstance(GlobalShortcuts, .{}),
|
|
};
|
|
|
|
// Signals
|
|
_ = gobject.Object.signals.notify.connect(
|
|
self,
|
|
*Self,
|
|
propConfig,
|
|
self,
|
|
.{ .detail = "config" },
|
|
);
|
|
|
|
// Trigger initial config changes
|
|
self.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec);
|
|
|
|
return self;
|
|
}
|
|
|
|
/// Force deinitialize the application.
|
|
///
|
|
/// Normally in a GObject lifecycle, this would be called by the
|
|
/// finalizer. But applications are never fully unreferenced so this
|
|
/// ensures that our memory is cleaned up properly.
|
|
pub fn deinit(self: *Self) void {
|
|
const alloc = self.allocator();
|
|
const priv = self.private();
|
|
priv.config.unref();
|
|
priv.winproto.deinit(alloc);
|
|
priv.global_shortcuts.unref();
|
|
if (priv.transient_cgroup_base) |base| alloc.free(base);
|
|
if (gdk.Display.getDefault()) |display| {
|
|
gtk.StyleContext.removeProviderForDisplay(
|
|
display,
|
|
priv.css_provider.as(gtk.StyleProvider),
|
|
);
|
|
|
|
for (priv.custom_css_providers.items) |provider| {
|
|
gtk.StyleContext.removeProviderForDisplay(
|
|
display,
|
|
provider.as(gtk.StyleProvider),
|
|
);
|
|
}
|
|
}
|
|
priv.css_provider.unref();
|
|
for (priv.custom_css_providers.items) |provider| provider.unref();
|
|
priv.custom_css_providers.deinit(alloc);
|
|
}
|
|
|
|
/// The global allocator that all other classes should use by
|
|
/// calling `Application.default().allocator()`. Zig code should prefer
|
|
/// this wherever possible so we get leak detection in debug/tests.
|
|
pub fn allocator(self: *Self) std.mem.Allocator {
|
|
return self.private().core_app.alloc;
|
|
}
|
|
|
|
/// Run the application. This is a replacement for `gio.Application.run`
|
|
/// because we want more tight control over our event loop so we can
|
|
/// integrate it with libghostty.
|
|
pub fn run(self: *Self) !void {
|
|
// Based on the actual `gio.Application.run` implementation:
|
|
// https://github.com/GNOME/glib/blob/a8e8b742e7926e33eb635a8edceac74cf239d6ed/gio/gapplication.c#L2533
|
|
|
|
// Acquire the default context for the application
|
|
const ctx = glib.MainContext.default();
|
|
if (glib.MainContext.acquire(ctx) == 0) return error.ContextAcquireFailed;
|
|
|
|
// The final cleanup that is always required at the end of running.
|
|
defer {
|
|
// Ensure our timer source is removed
|
|
self.stopQuitTimer();
|
|
|
|
// Sync any remaining settings
|
|
gio.Settings.sync();
|
|
|
|
// Clear out the event loop, don't block.
|
|
while (glib.MainContext.iteration(ctx, 0) != 0) {}
|
|
|
|
// Release the context so something else can use it.
|
|
defer glib.MainContext.release(ctx);
|
|
}
|
|
|
|
// Register the application
|
|
var err_: ?*glib.Error = null;
|
|
if (self.as(gio.Application).register(
|
|
null,
|
|
&err_,
|
|
) == 0) {
|
|
if (err_) |err| {
|
|
defer err.free();
|
|
log.warn(
|
|
"error registering application: {s}",
|
|
.{err.f_message orelse "(unknown)"},
|
|
);
|
|
}
|
|
|
|
return error.ApplicationRegisterFailed;
|
|
}
|
|
assert(err_ == null);
|
|
|
|
// This just calls the `activate` signal but its part of the normal startup
|
|
// routine so we just call it, but only if the config allows it (this allows
|
|
// for launching Ghostty in the "background" without immediately opening
|
|
// a window).
|
|
//
|
|
// https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302
|
|
const priv = self.private();
|
|
{
|
|
// We need to scope any config access because once we run our
|
|
// event loop, this can change out from underneath us.
|
|
const config = priv.config.get();
|
|
if (config.@"initial-window") self.as(gio.Application).activate();
|
|
}
|
|
|
|
// If we are NOT the primary instance, then we never want to run.
|
|
// This means that another instance of the GTK app is running.
|
|
if (self.as(gio.Application).getIsRemote() != 0) {
|
|
log.debug(
|
|
"application is remote, exiting run loop after activation",
|
|
.{},
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Tell systemd that we are ready.
|
|
systemd.notify.ready();
|
|
|
|
log.debug("entering runloop", .{});
|
|
defer log.debug("exiting runloop", .{});
|
|
priv.running = true;
|
|
while (priv.running) {
|
|
_ = glib.MainContext.iteration(ctx, 1);
|
|
|
|
// Tick the core Ghostty terminal app
|
|
try priv.core_app.tick(priv.rt_app);
|
|
|
|
// Check if we must quit based on the current state.
|
|
const must_quit = q: {
|
|
// If we are configured to always stay running, don't quit.
|
|
const config = priv.config.get();
|
|
if (!config.@"quit-after-last-window-closed") break :q false;
|
|
|
|
// If the quit timer has expired, quit.
|
|
if (priv.quit_timer == .expired) break :q true;
|
|
|
|
// If we have no windows attached to our app, also quit.
|
|
if (priv.requested_window and @as(
|
|
?*glib.List,
|
|
self.as(gtk.Application).getWindows(),
|
|
) == null) break :q true;
|
|
|
|
// No quit conditions met
|
|
break :q false;
|
|
};
|
|
|
|
if (must_quit) {
|
|
// All must quit scenarios do not need confirmation.
|
|
// Furthermore, must quit scenarios may result in a situation
|
|
// where its unsafe to even access the app/surface memory
|
|
// since its in the process of being freed. We must simply
|
|
// begin our exit immediately.
|
|
self.quitNow();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Quit the application. This will start the process to stop the
|
|
/// run loop. It will not `posix.exit`.
|
|
pub fn quit(self: *Self) void {
|
|
const priv = self.private();
|
|
|
|
// If our run loop has already exited then we are done.
|
|
if (!priv.running) return;
|
|
|
|
// If our core app doesn't need to confirm quit then we
|
|
// can exit immediately.
|
|
if (!priv.core_app.needsConfirmQuit()) {
|
|
self.quitNow();
|
|
return;
|
|
}
|
|
|
|
// Get the parent for our dialog
|
|
const parent: ?*gtk.Widget = parent: {
|
|
const list = gtk.Window.listToplevels();
|
|
defer list.free();
|
|
const focused = @as(?*glib.List, list.findCustom(
|
|
null,
|
|
findActiveWindow,
|
|
)) orelse {
|
|
// If we have an active surface then we should have
|
|
// a window available but in the rare case we don't we
|
|
// should exit so we don't crash.
|
|
break :parent null;
|
|
};
|
|
break :parent @ptrCast(@alignCast(focused.f_data));
|
|
};
|
|
|
|
// Show a confirmation dialog
|
|
const dialog: *CloseConfirmationDialog = .new(.app);
|
|
_ = CloseConfirmationDialog.signals.@"close-request".connect(
|
|
dialog,
|
|
*Application,
|
|
handleCloseConfirmation,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
// Show it
|
|
dialog.present(parent);
|
|
}
|
|
|
|
fn quitNow(self: *Self) void {
|
|
// Get all our windows and destroy them, forcing them to free.
|
|
const list = gtk.Window.listToplevels();
|
|
defer list.free();
|
|
list.foreach(struct {
|
|
fn callback(data: ?*anyopaque, _: ?*anyopaque) callconv(.c) void {
|
|
const ptr = data orelse return;
|
|
const window: *gtk.Window = @ptrCast(@alignCast(ptr));
|
|
|
|
// We only want to destroy our windows. These windows own
|
|
// every other type of window that is possible so this will
|
|
// trigger a proper shutdown sequence.
|
|
//
|
|
// We previously just destroyed ALL windows but this leads to
|
|
// a double-free with the fcitx ime, because it has a nested
|
|
// gtk.Window as a property that we don't own and it later
|
|
// tries to free on its own. I think this is probably a bug in
|
|
// the fcitx ime widget but still, we don't want a double free!
|
|
if (gobject.ext.isA(window, Window)) {
|
|
window.destroy();
|
|
}
|
|
}
|
|
}.callback, null);
|
|
|
|
// Trigger our runloop exit.
|
|
self.private().running = false;
|
|
}
|
|
|
|
/// apprt API to perform an action.
|
|
pub fn performAction(
|
|
self: *Self,
|
|
target: apprt.Target,
|
|
comptime action: apprt.Action.Key,
|
|
value: apprt.Action.Value(action),
|
|
) !bool {
|
|
switch (action) {
|
|
.close_tab => return Action.closeTab(target, value),
|
|
.close_window => return Action.closeWindow(target),
|
|
|
|
.config_change => try Action.configChange(
|
|
self,
|
|
target,
|
|
value.config,
|
|
),
|
|
|
|
.desktop_notification => Action.desktopNotification(self, target, value),
|
|
|
|
.equalize_splits => return Action.equalizeSplits(target),
|
|
|
|
.goto_split => return Action.gotoSplit(target, value),
|
|
|
|
.goto_tab => return Action.gotoTab(target, value),
|
|
|
|
.initial_size => return Action.initialSize(target, value),
|
|
|
|
.inspector => return Action.controlInspector(target, value),
|
|
|
|
.mouse_over_link => Action.mouseOverLink(target, value),
|
|
.mouse_shape => Action.mouseShape(target, value),
|
|
.mouse_visibility => Action.mouseVisibility(target, value),
|
|
|
|
.move_tab => return Action.moveTab(target, value),
|
|
|
|
.new_split => return Action.newSplit(target, value),
|
|
|
|
.new_tab => return Action.newTab(target),
|
|
|
|
.new_window => try Action.newWindow(
|
|
self,
|
|
switch (target) {
|
|
.app => null,
|
|
.surface => |v| v,
|
|
},
|
|
),
|
|
|
|
.open_config => return Action.openConfig(self),
|
|
|
|
.open_url => Action.openUrl(self, value),
|
|
|
|
.pwd => Action.pwd(target, value),
|
|
|
|
.present_terminal => return Action.presentTerminal(target),
|
|
|
|
.progress_report => return Action.progressReport(target, value),
|
|
|
|
.prompt_title => return Action.promptTitle(target),
|
|
|
|
.quit => self.quit(),
|
|
|
|
.quit_timer => try Action.quitTimer(self, value),
|
|
|
|
.reload_config => try Action.reloadConfig(self, target, value),
|
|
|
|
.render => Action.render(target),
|
|
|
|
.resize_split => return Action.resizeSplit(target, value),
|
|
|
|
.ring_bell => Action.ringBell(target),
|
|
|
|
.set_title => Action.setTitle(target, value),
|
|
|
|
.show_child_exited => return Action.showChildExited(target, value),
|
|
|
|
.show_gtk_inspector => Action.showGtkInspector(),
|
|
|
|
.size_limit => return Action.sizeLimit(target, value),
|
|
|
|
.toggle_maximize => Action.toggleMaximize(target),
|
|
.toggle_fullscreen => Action.toggleFullscreen(target),
|
|
.toggle_quick_terminal => return Action.toggleQuickTerminal(self),
|
|
.toggle_tab_overview => return Action.toggleTabOverview(target),
|
|
.toggle_window_decorations => return Action.toggleWindowDecorations(target),
|
|
.toggle_command_palette => return Action.toggleCommandPalette(target),
|
|
.toggle_split_zoom => return Action.toggleSplitZoom(target),
|
|
.show_on_screen_keyboard => return Action.showOnScreenKeyboard(target),
|
|
|
|
// Unimplemented
|
|
.secure_input,
|
|
.close_all_windows,
|
|
.float_window,
|
|
.toggle_visibility,
|
|
.cell_size,
|
|
.key_sequence,
|
|
.render_inspector,
|
|
.renderer_health,
|
|
.color_change,
|
|
.reset_window_size,
|
|
.check_for_updates,
|
|
.undo,
|
|
.redo,
|
|
=> {
|
|
log.warn("unimplemented action={}", .{action});
|
|
return false;
|
|
},
|
|
}
|
|
|
|
// Assume it was handled. The unhandled case must be explicit
|
|
// in the switch above.
|
|
return true;
|
|
}
|
|
|
|
/// Returns the core app associated with this application. This is
|
|
/// not a reference-counted type so you should not store this.
|
|
pub fn core(self: *Self) *CoreApp {
|
|
return self.private().core_app;
|
|
}
|
|
|
|
/// Returns the apprt application associated with this application.
|
|
pub fn rt(self: *Self) *ApprtApp {
|
|
return self.private().rt_app;
|
|
}
|
|
|
|
/// Returns the app winproto implementation.
|
|
pub fn winproto(self: *Self) *winprotopkg.App {
|
|
return &self.private().winproto;
|
|
}
|
|
|
|
/// Returns the cgroup base (if any).
|
|
pub fn cgroupBase(self: *Self) ?[]const u8 {
|
|
return self.private().transient_cgroup_base;
|
|
}
|
|
|
|
/// This will get called when there are no more open surfaces.
|
|
fn startQuitTimer(self: *Self) void {
|
|
const priv = self.private();
|
|
const config = priv.config.get();
|
|
|
|
// Cancel any previous timer.
|
|
self.stopQuitTimer();
|
|
|
|
// This is a no-op unless we are configured to quit after last window is closed.
|
|
if (!config.@"quit-after-last-window-closed") return;
|
|
|
|
// If a delay is configured, set a timeout function to quit after the delay.
|
|
if (config.@"quit-after-last-window-closed-delay") |v| {
|
|
priv.quit_timer = .{
|
|
.active = glib.timeoutAdd(
|
|
v.asMilliseconds(),
|
|
handleQuitTimerExpired,
|
|
self,
|
|
),
|
|
};
|
|
} else {
|
|
// If no delay is configured, treat it as expired.
|
|
priv.quit_timer = .expired;
|
|
}
|
|
}
|
|
|
|
/// This will get called when a new surface gets opened.
|
|
fn stopQuitTimer(self: *Self) void {
|
|
const priv = self.private();
|
|
switch (priv.quit_timer) {
|
|
.off => {},
|
|
.expired => priv.quit_timer = .off,
|
|
.active => |source| {
|
|
if (glib.Source.remove(source) == 0) {
|
|
log.warn(
|
|
"unable to remove quit timer source={d}",
|
|
.{source},
|
|
);
|
|
}
|
|
|
|
priv.quit_timer = .off;
|
|
},
|
|
}
|
|
}
|
|
|
|
fn loadRuntimeCss(self: *Self) Allocator.Error!void {
|
|
const alloc = self.allocator();
|
|
|
|
const config = self.private().config.get();
|
|
|
|
var buf: std.ArrayListUnmanaged(u8) = try .initCapacity(alloc, 2048);
|
|
defer buf.deinit(alloc);
|
|
|
|
const writer = buf.writer(alloc);
|
|
|
|
const unfocused_fill: CoreConfig.Color = config.@"unfocused-split-fill" orelse config.background;
|
|
|
|
try writer.print(
|
|
\\widget.unfocused-split {{
|
|
\\ opacity: {d:.2};
|
|
\\ background-color: rgb({d},{d},{d});
|
|
\\}}
|
|
\\
|
|
, .{
|
|
1.0 - config.@"unfocused-split-opacity",
|
|
unfocused_fill.r,
|
|
unfocused_fill.g,
|
|
unfocused_fill.b,
|
|
});
|
|
|
|
if (config.@"split-divider-color") |color| {
|
|
try writer.print(
|
|
\\.window .split paned > separator {{
|
|
\\ color: rgb({[r]d},{[g]d},{[b]d});
|
|
\\ background: rgb({[r]d},{[g]d},{[b]d});
|
|
\\}}
|
|
\\
|
|
, .{
|
|
.r = color.r,
|
|
.g = color.g,
|
|
.b = color.b,
|
|
});
|
|
}
|
|
|
|
if (config.@"window-title-font-family") |font_family| {
|
|
try writer.print(
|
|
\\.window headerbar {{
|
|
\\ font-family: "{[font_family]s}";
|
|
\\}}
|
|
\\
|
|
, .{ .font_family = font_family });
|
|
}
|
|
|
|
try loadRuntimeCss414(config, &writer);
|
|
try loadRuntimeCss416(config, &writer);
|
|
|
|
// ensure that we have a sentinel
|
|
try writer.writeByte(0);
|
|
|
|
const data = buf.items[0 .. buf.items.len - 1 :0];
|
|
|
|
log.debug("runtime CSS is {d} bytes", .{data.len + 1});
|
|
|
|
// Clears any previously loaded CSS from this provider
|
|
loadCssProviderFromData(
|
|
self.private().css_provider,
|
|
data,
|
|
);
|
|
}
|
|
|
|
/// Load runtime CSS for older than GTK 4.16
|
|
fn loadRuntimeCss414(
|
|
config: *const CoreConfig,
|
|
writer: *const std.ArrayListUnmanaged(u8).Writer,
|
|
) Allocator.Error!void {
|
|
if (gtk_version.runtimeAtLeast(4, 16, 0)) return;
|
|
|
|
const window_theme = config.@"window-theme";
|
|
const headerbar_background = config.@"window-titlebar-background" orelse config.background;
|
|
const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground;
|
|
|
|
switch (window_theme) {
|
|
.ghostty => try writer.print(
|
|
\\windowhandle {{
|
|
\\ background-color: rgb({d},{d},{d});
|
|
\\ color: rgb({d},{d},{d});
|
|
\\}}
|
|
\\windowhandle:backdrop {{
|
|
\\ background-color: oklab(from rgb({d},{d},{d}) calc(l * 0.9) a b / alpha);
|
|
\\}}
|
|
\\
|
|
, .{
|
|
headerbar_background.r,
|
|
headerbar_background.g,
|
|
headerbar_background.b,
|
|
headerbar_foreground.r,
|
|
headerbar_foreground.g,
|
|
headerbar_foreground.b,
|
|
headerbar_background.r,
|
|
headerbar_background.g,
|
|
headerbar_background.b,
|
|
}),
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
/// Load runtime for GTK 4.16 and newer
|
|
fn loadRuntimeCss416(
|
|
config: *const CoreConfig,
|
|
writer: *const std.ArrayListUnmanaged(u8).Writer,
|
|
) Allocator.Error!void {
|
|
if (gtk_version.runtimeUntil(4, 16, 0)) return;
|
|
|
|
const window_theme = config.@"window-theme";
|
|
const headerbar_background = config.@"window-titlebar-background" orelse config.background;
|
|
const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground;
|
|
|
|
try writer.writeAll(
|
|
\\/*
|
|
\\ * Child Exited Overlay
|
|
\\ */
|
|
\\
|
|
\\.child-exited.normal revealer widget {
|
|
\\ background-color: color-mix(
|
|
\\ in srgb,
|
|
\\ var(--success-bg-color),
|
|
\\ transparent 50%
|
|
\\ );
|
|
\\}
|
|
\\
|
|
\\.child-exited.abnormal revealer widget {
|
|
\\ background-color: color-mix(
|
|
\\ in srgb,
|
|
\\ var(--error-bg-color),
|
|
\\ transparent 50%
|
|
\\ );
|
|
\\}
|
|
\\
|
|
\\/*
|
|
\\ * Surface
|
|
\\ */
|
|
\\
|
|
\\.surface progressbar.error trough progress {
|
|
\\ background-color: color-mix(
|
|
\\ in srgb,
|
|
\\ var(--error-bg-color),
|
|
\\ transparent 50%
|
|
\\ );
|
|
\\}
|
|
\\
|
|
\\.surface .bell-overlay {
|
|
\\ border-color: color-mix(
|
|
\\ in srgb,
|
|
\\ var(--accent-color),
|
|
\\ transparent 50%
|
|
\\ );
|
|
\\}
|
|
\\
|
|
\\/*
|
|
\\ * Splits
|
|
\\ */
|
|
\\
|
|
\\.window .split paned > separator {
|
|
\\ background-color: color-mix(
|
|
\\ in srgb,
|
|
\\ var(--window-bg-color),
|
|
\\ transparent 0%
|
|
\\ );
|
|
\\}
|
|
\\
|
|
);
|
|
|
|
switch (window_theme) {
|
|
.ghostty => try writer.print(
|
|
\\:root {{
|
|
\\ --ghostty-fg: rgb({d},{d},{d});
|
|
\\ --ghostty-bg: rgb({d},{d},{d});
|
|
\\ --headerbar-fg-color: var(--ghostty-fg);
|
|
\\ --headerbar-bg-color: var(--ghostty-bg);
|
|
\\ --headerbar-backdrop-color: oklab(from var(--headerbar-bg-color) calc(l * 0.9) a b / alpha);
|
|
\\ --overview-fg-color: var(--ghostty-fg);
|
|
\\ --overview-bg-color: var(--ghostty-bg);
|
|
\\ --popover-fg-color: var(--ghostty-fg);
|
|
\\ --popover-bg-color: var(--ghostty-bg);
|
|
\\ --window-fg-color: var(--ghostty-fg);
|
|
\\ --window-bg-color: var(--ghostty-bg);
|
|
\\}}
|
|
\\windowhandle {{
|
|
\\ background-color: var(--headerbar-bg-color);
|
|
\\ color: var(--headerbar-fg-color);
|
|
\\}}
|
|
\\windowhandle:backdrop {{
|
|
\\ background-color: var(--headerbar-backdrop-color);
|
|
\\}}
|
|
, .{
|
|
headerbar_foreground.r,
|
|
headerbar_foreground.g,
|
|
headerbar_foreground.b,
|
|
headerbar_background.r,
|
|
headerbar_background.g,
|
|
headerbar_background.b,
|
|
}),
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
fn loadCustomCss(self: *Self) !void {
|
|
const priv = self.private();
|
|
const alloc = self.allocator();
|
|
const display = gdk.Display.getDefault() orelse {
|
|
log.warn("unable to get display", .{});
|
|
return;
|
|
};
|
|
|
|
// unload the previously loaded style providers
|
|
for (priv.custom_css_providers.items) |provider| {
|
|
gtk.StyleContext.removeProviderForDisplay(
|
|
display,
|
|
provider.as(gtk.StyleProvider),
|
|
);
|
|
provider.unref();
|
|
}
|
|
priv.custom_css_providers.clearRetainingCapacity();
|
|
|
|
const config = priv.config.getMut();
|
|
for (config.@"gtk-custom-css".value.items) |p| {
|
|
const path, const optional = switch (p) {
|
|
.optional => |path| .{ path, true },
|
|
.required => |path| .{ path, false },
|
|
};
|
|
const file = std.fs.openFileAbsolute(path, .{}) catch |err| {
|
|
if (err != error.FileNotFound or !optional) {
|
|
log.warn(
|
|
"error opening gtk-custom-css file {s}: {}",
|
|
.{ path, err },
|
|
);
|
|
}
|
|
continue;
|
|
};
|
|
defer file.close();
|
|
|
|
log.info("loading gtk-custom-css path={s}", .{path});
|
|
const contents = try file.reader().readAllAlloc(
|
|
alloc,
|
|
5 * 1024 * 1024, // 5MB,
|
|
);
|
|
defer alloc.free(contents);
|
|
|
|
const data = try alloc.dupeZ(u8, contents);
|
|
defer alloc.free(data);
|
|
|
|
const provider = gtk.CssProvider.new();
|
|
errdefer provider.unref();
|
|
try priv.custom_css_providers.append(alloc, provider);
|
|
loadCssProviderFromData(provider, data);
|
|
gtk.StyleContext.addProviderForDisplay(
|
|
display,
|
|
provider.as(gtk.StyleProvider),
|
|
gtk.STYLE_PROVIDER_PRIORITY_USER,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn syncActionAccelerators(self: *Self) void {
|
|
self.syncActionAccelerator("app.quit", .{ .quit = {} });
|
|
self.syncActionAccelerator("app.open-config", .{ .open_config = {} });
|
|
self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} });
|
|
self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle });
|
|
self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector);
|
|
self.syncActionAccelerator("win.toggle-command-palette", .toggle_command_palette);
|
|
self.syncActionAccelerator("win.close", .{ .close_window = {} });
|
|
self.syncActionAccelerator("win.new-window", .{ .new_window = {} });
|
|
self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} });
|
|
self.syncActionAccelerator("win.close-tab::this", .{ .close_tab = .this });
|
|
self.syncActionAccelerator("tab.close::this", .{ .close_tab = .this });
|
|
self.syncActionAccelerator("win.split-right", .{ .new_split = .right });
|
|
self.syncActionAccelerator("win.split-down", .{ .new_split = .down });
|
|
self.syncActionAccelerator("win.split-left", .{ .new_split = .left });
|
|
self.syncActionAccelerator("win.split-up", .{ .new_split = .up });
|
|
self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = {} });
|
|
self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} });
|
|
self.syncActionAccelerator("win.reset", .{ .reset = {} });
|
|
self.syncActionAccelerator("win.clear", .{ .clear_screen = {} });
|
|
self.syncActionAccelerator("win.prompt-title", .{ .prompt_surface_title = {} });
|
|
self.syncActionAccelerator("split-tree.new-split::left", .{ .new_split = .left });
|
|
self.syncActionAccelerator("split-tree.new-split::right", .{ .new_split = .right });
|
|
self.syncActionAccelerator("split-tree.new-split::up", .{ .new_split = .up });
|
|
self.syncActionAccelerator("split-tree.new-split::down", .{ .new_split = .down });
|
|
}
|
|
|
|
fn syncActionAccelerator(
|
|
self: *Self,
|
|
gtk_action: [:0]const u8,
|
|
action: input.Binding.Action,
|
|
) void {
|
|
const gtk_app = self.as(gtk.Application);
|
|
|
|
// Reset it initially
|
|
const zero = [_:null]?[*:0]const u8{};
|
|
gtk_app.setAccelsForAction(gtk_action, &zero);
|
|
|
|
const config = self.private().config.get();
|
|
const trigger = config.keybind.set.getTrigger(action) orelse return;
|
|
var buf: [1024]u8 = undefined;
|
|
const accel = if (key.accelFromTrigger(
|
|
&buf,
|
|
trigger,
|
|
)) |accel_|
|
|
accel_ orelse return
|
|
else |err| switch (err) {
|
|
// This should really never, never happen. Its not critical enough
|
|
// to actually crash, but this is a bug somewhere. An accelerator
|
|
// for a trigger can't possibly be more than 1024 bytes.
|
|
error.NoSpaceLeft => {
|
|
log.warn("accelerator somehow longer than 1024 bytes: {}", .{trigger});
|
|
return;
|
|
},
|
|
};
|
|
const accels = [_:null]?[*:0]const u8{accel};
|
|
|
|
gtk_app.setAccelsForAction(gtk_action, &accels);
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Properties
|
|
|
|
/// Returns the configuration for this application.
|
|
///
|
|
/// The reference count is increased.
|
|
pub fn getConfig(self: *Self) *Config {
|
|
return self.private().config.ref();
|
|
}
|
|
|
|
/// Set the configuration for this application. The reference count
|
|
/// is increased on the new configuration and the old one is
|
|
/// unreferenced.
|
|
///
|
|
/// If the config has errors this may show the config errors dialog.
|
|
fn setConfig(self: *Self, config: *Config) void {
|
|
const priv = self.private();
|
|
priv.config.unref();
|
|
priv.config = config.ref();
|
|
self.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec);
|
|
|
|
// Show our errors if we have any
|
|
self.showConfigErrorsDialog();
|
|
}
|
|
|
|
fn propConfig(
|
|
_: *Application,
|
|
_: *gobject.ParamSpec,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
// Sync our accelerators for menu items.
|
|
self.syncActionAccelerators();
|
|
|
|
// Load our runtime and custom CSS. If this fails then our window is
|
|
// just stuck with the old CSS but we don't want to fail the entire
|
|
// config change operation.
|
|
self.loadRuntimeCss() catch |err| switch (err) {
|
|
error.OutOfMemory => log.warn(
|
|
"out of memory loading runtime CSS, no runtime CSS applied",
|
|
.{},
|
|
),
|
|
};
|
|
self.loadCustomCss() catch |err| {
|
|
log.warn(
|
|
"failed to load custom CSS, no custom CSS applied, err={}",
|
|
.{err},
|
|
);
|
|
};
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Libghostty Callbacks
|
|
|
|
pub fn wakeup(self: *Self) void {
|
|
_ = self;
|
|
glib.MainContext.wakeup(null);
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Virtual Methods
|
|
|
|
fn startup(self: *Self) callconv(.c) void {
|
|
log.debug("startup", .{});
|
|
|
|
gio.Application.virtual_methods.startup.call(
|
|
Class.parent,
|
|
self.as(Parent),
|
|
);
|
|
|
|
// Set ourselves as the default application.
|
|
gio.Application.setDefault(self.as(gio.Application));
|
|
|
|
// Setup our event loop
|
|
self.startupXev();
|
|
|
|
// Setup our style manager (light/dark mode)
|
|
self.startupStyleManager();
|
|
|
|
// Setup some signal handlers
|
|
self.startupSignals();
|
|
|
|
// Setup our action map
|
|
self.startupActionMap();
|
|
|
|
// Setup our global shortcuts
|
|
self.startupGlobalShortcuts();
|
|
|
|
// Setup our cgroup for the application.
|
|
self.startupCgroup() catch |err| {
|
|
log.warn("cgroup initialization failed err={}", .{err});
|
|
|
|
// Add it to our config diagnostics so it shows up in a GUI dialog.
|
|
// Admittedly this has two issues: (1) we shuldn't be using the
|
|
// config errors dialog for this long term and (2) using a mut
|
|
// ref to the config wouldn't propagate changes to UI properly,
|
|
// but we're in startup mode so its okay.
|
|
const config = self.private().config.getMut();
|
|
config.addDiagnosticFmt(
|
|
"cgroup initialization failed: {}",
|
|
.{err},
|
|
) catch {};
|
|
};
|
|
|
|
// If we have any config diagnostics from loading, then we
|
|
// show the diagnostics dialog. We show this one as a general
|
|
// modal (not to any specific window) because we don't even
|
|
// know if the window will load.
|
|
self.showConfigErrorsDialog();
|
|
}
|
|
|
|
/// Configure libxev to use a specific backend.
|
|
///
|
|
/// This must be called before any other xev APIs are used.
|
|
fn startupXev(self: *Self) void {
|
|
const priv = self.private();
|
|
const config = priv.config.get();
|
|
|
|
// If our backend is auto then we have no setup to do.
|
|
if (config.@"async-backend" == .auto) return;
|
|
|
|
// Setup our event loop backend to the preferred method
|
|
const result: bool = switch (config.@"async-backend") {
|
|
.auto => unreachable,
|
|
.epoll => if (comptime xev.dynamic) xev.prefer(.epoll) else false,
|
|
.io_uring => if (comptime xev.dynamic) xev.prefer(.io_uring) else false,
|
|
};
|
|
|
|
if (result) {
|
|
log.info(
|
|
"libxev manual backend={s}",
|
|
.{@tagName(xev.backend)},
|
|
);
|
|
} else {
|
|
log.warn(
|
|
"libxev manual backend failed, using default={s}",
|
|
.{@tagName(xev.backend)},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Setup the style manager on startup. The primary task here is to
|
|
/// setup our initial light/dark mode based on the configuration and
|
|
/// setup listeners for changes to the style manager.
|
|
fn startupStyleManager(self: *Self) void {
|
|
const priv = self.private();
|
|
const config = priv.config.get();
|
|
|
|
// Setup our initial light/dark
|
|
const style = self.as(adw.Application).getStyleManager();
|
|
style.setColorScheme(switch (config.@"window-theme") {
|
|
.auto, .ghostty => auto: {
|
|
const lum = config.background.toTerminalRGB().perceivedLuminance();
|
|
break :auto if (lum > 0.5)
|
|
.prefer_light
|
|
else
|
|
.prefer_dark;
|
|
},
|
|
.system => .prefer_light,
|
|
.dark => .force_dark,
|
|
.light => .force_light,
|
|
});
|
|
|
|
// Setup color change notifications
|
|
_ = gobject.Object.signals.notify.connect(
|
|
style,
|
|
*Self,
|
|
handleStyleManagerDark,
|
|
self,
|
|
.{ .detail = "dark" },
|
|
);
|
|
|
|
// Do an initial color scheme sync. This is idempotent and does nothing
|
|
// if our current theme matches what libghostty has so its safe to
|
|
// call.
|
|
handleStyleManagerDark(style, undefined, self);
|
|
}
|
|
|
|
/// Setup signal handlers
|
|
fn startupSignals(self: *Self) void {
|
|
const priv = self.private();
|
|
assert(priv.signal_source == null);
|
|
priv.signal_source = glib.unixSignalAdd(
|
|
std.posix.SIG.USR2,
|
|
handleSigusr2,
|
|
self,
|
|
);
|
|
}
|
|
|
|
/// Setup our action map.
|
|
fn startupActionMap(self: *Self) void {
|
|
const t_variant_type = glib.ext.VariantType.newFor(u64);
|
|
defer t_variant_type.free();
|
|
|
|
const as_variant_type = glib.VariantType.new("as");
|
|
defer as_variant_type.free();
|
|
|
|
const actions = [_]ext.actions.Action(Self){
|
|
.init("new-window", actionNewWindow, null),
|
|
.init("new-window-command", actionNewWindow, as_variant_type),
|
|
.init("open-config", actionOpenConfig, null),
|
|
.init("present-surface", actionPresentSurface, t_variant_type),
|
|
.init("quit", actionQuit, null),
|
|
.init("reload-config", actionReloadConfig, null),
|
|
};
|
|
|
|
ext.actions.add(Self, self, &actions);
|
|
}
|
|
|
|
/// Setup our global shortcuts.
|
|
fn startupGlobalShortcuts(self: *Self) void {
|
|
const priv = self.private();
|
|
|
|
// On startup, our dbus connection should be available.
|
|
priv.global_shortcuts.setDbusConnection(
|
|
self.as(gio.Application).getDbusConnection(),
|
|
);
|
|
|
|
// Setup a binding so that the shortcut config always matches the app.
|
|
_ = gobject.Object.bindProperty(
|
|
self.as(gobject.Object),
|
|
"config",
|
|
priv.global_shortcuts.as(gobject.Object),
|
|
"config",
|
|
.{ .sync_create = true },
|
|
);
|
|
|
|
// Setup the signal handler for global shortcut triggers
|
|
_ = GlobalShortcuts.signals.trigger.connect(
|
|
priv.global_shortcuts,
|
|
*Application,
|
|
globalShortcutTrigger,
|
|
self,
|
|
.{},
|
|
);
|
|
}
|
|
|
|
const CgroupError = error{
|
|
DbusConnectionFailed,
|
|
CgroupInitFailed,
|
|
};
|
|
|
|
/// Setup our cgroup for the application, if enabled.
|
|
///
|
|
/// The setup for cgroups involves creating the cgroup for our
|
|
/// application, moving ourselves into it, and storing the base path
|
|
/// so that created surfaces can also have their own cgroups.
|
|
fn startupCgroup(self: *Self) CgroupError!void {
|
|
const priv = self.private();
|
|
const config = priv.config.get();
|
|
|
|
// If cgroup isolation isn't enabled then we don't do this.
|
|
if (!switch (config.@"linux-cgroup") {
|
|
.never => false,
|
|
.always => true,
|
|
.@"single-instance" => single: {
|
|
const flags = self.as(gio.Application).getFlags();
|
|
break :single !flags.non_unique;
|
|
},
|
|
}) {
|
|
log.info(
|
|
"cgroup isolation disabled via config={}",
|
|
.{config.@"linux-cgroup"},
|
|
);
|
|
return;
|
|
}
|
|
|
|
// We need a dbus connection to do anything else
|
|
const dbus = self.as(gio.Application).getDbusConnection() orelse {
|
|
if (config.@"linux-cgroup-hard-fail") {
|
|
log.err("dbus connection required for cgroup isolation, exiting", .{});
|
|
return error.DbusConnectionFailed;
|
|
}
|
|
|
|
return;
|
|
};
|
|
|
|
const alloc = priv.core_app.alloc;
|
|
const path = cgroup.init(alloc, dbus, .{
|
|
.memory_high = config.@"linux-cgroup-memory-limit",
|
|
.pids_max = config.@"linux-cgroup-processes-limit",
|
|
}) catch |err| {
|
|
// If we can't initialize cgroups then that's okay. We
|
|
// want to continue to run so we just won't isolate surfaces.
|
|
// NOTE(mitchellh): do we want a config to force it?
|
|
log.warn(
|
|
"failed to initialize cgroups, terminals will not be isolated err={}",
|
|
.{err},
|
|
);
|
|
|
|
// If we have hard fail enabled then we exit now.
|
|
if (config.@"linux-cgroup-hard-fail") {
|
|
log.err("linux-cgroup-hard-fail enabled, exiting", .{});
|
|
return error.CgroupInitFailed;
|
|
}
|
|
|
|
return;
|
|
};
|
|
|
|
log.info("cgroup isolation enabled base={s}", .{path});
|
|
priv.transient_cgroup_base = path;
|
|
}
|
|
|
|
fn activate(self: *Self) callconv(.c) void {
|
|
log.debug("activate", .{});
|
|
|
|
// Queue a new window
|
|
const priv = self.private();
|
|
_ = priv.core_app.mailbox.push(.{
|
|
.new_window = .{},
|
|
}, .{ .forever = {} });
|
|
|
|
// Call the parent activate method.
|
|
gio.Application.virtual_methods.activate.call(
|
|
Class.parent,
|
|
self.as(Parent),
|
|
);
|
|
}
|
|
|
|
fn dispose(self: *Self) callconv(.c) void {
|
|
const priv = self.private();
|
|
if (priv.config_errors_dialog.get()) |diag| {
|
|
diag.close();
|
|
diag.unref(); // strong ref from get()
|
|
}
|
|
priv.config_errors_dialog.set(null);
|
|
if (priv.signal_source) |v| {
|
|
if (glib.Source.remove(v) == 0) {
|
|
log.warn("unable to remove signal source", .{});
|
|
}
|
|
priv.signal_source = null;
|
|
}
|
|
|
|
gobject.Object.virtual_methods.dispose.call(
|
|
Class.parent,
|
|
self.as(Parent),
|
|
);
|
|
}
|
|
|
|
fn finalize(self: *Self) callconv(.c) void {
|
|
self.deinit();
|
|
gobject.Object.virtual_methods.finalize.call(
|
|
Class.parent,
|
|
self.as(Parent),
|
|
);
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Signal Handlers
|
|
|
|
/// SIGUSR2 signal handler via g_unix_signal_add
|
|
fn handleSigusr2(ud: ?*anyopaque) callconv(.c) c_int {
|
|
const self: *Self = @ptrCast(@alignCast(ud orelse
|
|
return @intFromBool(glib.SOURCE_CONTINUE)));
|
|
|
|
log.info("received SIGUSR2, reloading configuration", .{});
|
|
Action.reloadConfig(
|
|
self,
|
|
.app,
|
|
.{},
|
|
) catch |err| {
|
|
// If we fail to reload the configuration, then we want the
|
|
// user to know it. For now we log but we should show another
|
|
// GUI.
|
|
log.warn("error reloading config: {}", .{err});
|
|
};
|
|
|
|
return @intFromBool(glib.SOURCE_CONTINUE);
|
|
}
|
|
|
|
fn handleCloseConfirmation(
|
|
_: *CloseConfirmationDialog,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
self.quitNow();
|
|
}
|
|
|
|
fn handleQuitTimerExpired(ud: ?*anyopaque) callconv(.c) c_int {
|
|
const self: *Self = @ptrCast(@alignCast(ud));
|
|
const priv = self.private();
|
|
priv.quit_timer = .expired;
|
|
return 0;
|
|
}
|
|
|
|
fn handleStyleManagerDark(
|
|
style: *adw.StyleManager,
|
|
_: *gobject.ParamSpec,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
const scheme: apprt.ColorScheme = if (style.getDark() == 0)
|
|
.light
|
|
else
|
|
.dark;
|
|
log.debug("style manager changed scheme={}", .{scheme});
|
|
|
|
const priv = self.private();
|
|
const core_app = priv.core_app;
|
|
core_app.colorSchemeEvent(self.rt(), scheme) catch |err| {
|
|
log.warn("error updating app color scheme err={}", .{err});
|
|
};
|
|
for (core_app.surfaces.items) |surface| {
|
|
surface.core().colorSchemeCallback(scheme) catch |err| {
|
|
log.warn(
|
|
"unable to tell surface about color scheme change err={}",
|
|
.{err},
|
|
);
|
|
};
|
|
}
|
|
}
|
|
|
|
fn handleReloadConfig(
|
|
_: *ConfigErrorsDialog,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
// We clear our dialog reference because its going to close
|
|
// after response handling and we don't want to reuse it.
|
|
const priv = self.private();
|
|
priv.config_errors_dialog.set(null);
|
|
|
|
// Reload our config as if the app reloaded.
|
|
Action.reloadConfig(
|
|
self,
|
|
.app,
|
|
.{},
|
|
) catch |err| {
|
|
// If we fail to reload the configuration, then we want the
|
|
// user to know it. For now we log but we should show another
|
|
// GUI.
|
|
log.warn("error reloading config: {}", .{err});
|
|
};
|
|
}
|
|
|
|
/// Show the config errors dialog if the config on our application
|
|
/// has diagnostics.
|
|
fn showConfigErrorsDialog(self: *Self) void {
|
|
const priv = self.private();
|
|
|
|
// If we already have a dialog, just update the config.
|
|
if (priv.config_errors_dialog.get()) |diag| {
|
|
defer diag.unref(); // get gets a strong ref
|
|
|
|
var value = gobject.ext.Value.newFrom(priv.config);
|
|
defer value.unset();
|
|
gobject.Object.setProperty(
|
|
diag.as(gobject.Object),
|
|
"config",
|
|
&value,
|
|
);
|
|
|
|
if (!priv.config.hasDiagnostics()) {
|
|
diag.close();
|
|
} else {
|
|
diag.present(null);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// No diagnostics, do nothing.
|
|
if (!priv.config.hasDiagnostics()) return;
|
|
|
|
// No dialog yet, initialize a new one. There's no need to unref
|
|
// here because the widget that it becomes a part of takes ownership.
|
|
const dialog: *ConfigErrorsDialog = .new(priv.config);
|
|
priv.config_errors_dialog.set(dialog);
|
|
|
|
// Connect to the reload signal so we know to reload our config.
|
|
_ = ConfigErrorsDialog.signals.@"reload-config".connect(
|
|
dialog,
|
|
*Application,
|
|
handleReloadConfig,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
// Show it
|
|
dialog.present(null);
|
|
}
|
|
|
|
fn globalShortcutTrigger(
|
|
_: *GlobalShortcuts,
|
|
action: *const Binding.Action,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
self.core().performAllAction(self.rt(), action.*) catch |err| {
|
|
log.warn("failed to perform action={}", .{err});
|
|
};
|
|
}
|
|
|
|
fn actionReloadConfig(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
const priv = self.private();
|
|
priv.core_app.performAction(self.rt(), .reload_config) catch |err| {
|
|
log.warn("error reloading config err={}", .{err});
|
|
};
|
|
}
|
|
|
|
fn actionQuit(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
const priv = self.private();
|
|
priv.core_app.performAction(self.rt(), .quit) catch |err| {
|
|
log.warn("error quitting err={}", .{err});
|
|
};
|
|
}
|
|
|
|
/// Handle `app.new-window` and `app.new-window-command` GTK actions
|
|
pub fn actionNewWindow(
|
|
_: *gio.SimpleAction,
|
|
parameter_: ?*glib.Variant,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
log.debug("received new window action", .{});
|
|
|
|
parameter: {
|
|
// were we given a parameter?
|
|
const parameter = parameter_ orelse break :parameter;
|
|
|
|
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;
|
|
}
|
|
|
|
const s_variant_type = glib.VariantType.new("s");
|
|
defer s_variant_type.free();
|
|
|
|
var it: glib.VariantIter = undefined;
|
|
_ = it.init(parameter);
|
|
|
|
while (it.nextValue()) |value| {
|
|
defer value.unref();
|
|
|
|
// just to be sure
|
|
if (value.isOfType(s_variant_type) == 0) continue;
|
|
|
|
var len: usize = undefined;
|
|
const buf = value.getString(&len);
|
|
const str = buf[0..len];
|
|
|
|
log.debug("new-window command argument: {s}", .{str});
|
|
}
|
|
}
|
|
|
|
_ = self.core().mailbox.push(.{
|
|
.new_window = .{},
|
|
}, .{ .forever = {} });
|
|
}
|
|
|
|
pub fn actionOpenConfig(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
_ = self.core().mailbox.push(.open_config, .forever);
|
|
}
|
|
|
|
fn actionPresentSurface(
|
|
_: *gio.SimpleAction,
|
|
parameter_: ?*glib.Variant,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
const parameter = parameter_ orelse return;
|
|
|
|
const t = glib.ext.VariantType.newFor(u64);
|
|
defer glib.VariantType.free(t);
|
|
|
|
// Make sure that we've receiived a u64 from the system.
|
|
if (glib.Variant.isOfType(parameter, t) == 0) {
|
|
return;
|
|
}
|
|
|
|
// Convert that u64 to pointer to a core surface. A value of zero
|
|
// means that there was no target surface for the notification so
|
|
// we don't focus any surface.
|
|
//
|
|
// This is admittedly SUPER SUS and we should instead do what we
|
|
// do on macOS which is generate a UUID per surface and then pass
|
|
// that around. But, we do validate the pointer below so at worst
|
|
// this may result in focusing the wrong surface if the pointer was
|
|
// reused for a surface.
|
|
const ptr_int = parameter.getUint64();
|
|
if (ptr_int == 0) return;
|
|
const surface: *CoreSurface = @ptrFromInt(ptr_int);
|
|
|
|
// Send a message through the core app mailbox rather than presenting the
|
|
// surface directly so that it can validate that the surface pointer is
|
|
// valid. We could get an invalid pointer if a desktop notification outlives
|
|
// a Ghostty instance and a new one starts up, or there are multiple Ghostty
|
|
// instances running.
|
|
_ = self.core().mailbox.push(
|
|
.{
|
|
.surface_message = .{
|
|
.surface = surface,
|
|
.message = .present_surface,
|
|
},
|
|
},
|
|
.forever,
|
|
);
|
|
}
|
|
|
|
//----------------------------------------------------------------
|
|
// Boilerplate/Noise
|
|
|
|
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 {
|
|
// Register our compiled resources exactly once.
|
|
{
|
|
const c = @cImport({
|
|
// generated header files
|
|
@cInclude("ghostty_resources.h");
|
|
});
|
|
if (c.ghostty_get_resource()) |ptr| {
|
|
gio.resourcesRegister(@ptrCast(@alignCast(ptr)));
|
|
} else {
|
|
// If we fail to load resources then things will
|
|
// probably look really bad but it shouldn't stop our
|
|
// app from loading.
|
|
log.warn("unable to load resources", .{});
|
|
}
|
|
}
|
|
|
|
// Properties
|
|
gobject.ext.registerProperties(class, &.{
|
|
properties.config.impl,
|
|
});
|
|
|
|
// Virtual methods
|
|
gio.Application.virtual_methods.activate.implement(class, &activate);
|
|
gio.Application.virtual_methods.startup.implement(class, &startup);
|
|
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
|
|
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
|
|
}
|
|
};
|
|
};
|
|
|
|
/// All apprt action handlers
|
|
const Action = struct {
|
|
pub fn closeTab(target: apprt.Target, value: apprt.Action.Value(.close_tab)) bool {
|
|
switch (target) {
|
|
.app => return false,
|
|
.surface => |core| {
|
|
const surface = core.rt_surface.surface;
|
|
return surface.as(gtk.Widget).activateAction(
|
|
"tab.close",
|
|
glib.ext.VariantType.stringFor([:0]const u8),
|
|
@as([*:0]const u8, @tagName(value)),
|
|
) != 0;
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn closeWindow(target: apprt.Target) bool {
|
|
switch (target) {
|
|
.app => return false,
|
|
.surface => |core| {
|
|
const surface = core.rt_surface.surface;
|
|
return surface.as(gtk.Widget).activateAction("win.close", null) != 0;
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn configChange(
|
|
self: *Application,
|
|
target: apprt.Target,
|
|
new_config: *const CoreConfig,
|
|
) !void {
|
|
// Wrap our config in a GObject. This will clone it.
|
|
const alloc = self.allocator();
|
|
const config_obj: *Config = try .new(alloc, new_config);
|
|
defer config_obj.unref();
|
|
|
|
switch (target) {
|
|
.surface => |core| core.rt_surface.surface.setConfig(config_obj),
|
|
.app => self.setConfig(config_obj),
|
|
}
|
|
}
|
|
|
|
pub fn desktopNotification(
|
|
self: *Application,
|
|
target: apprt.Target,
|
|
n: apprt.action.DesktopNotification,
|
|
) void {
|
|
// TODO: We should move the surface target to a function call
|
|
// on Surface and emit a signal that embedders can connect to. This
|
|
// will let us handle notifications differently depending on where
|
|
// a surface is presented. At the time of writing this, we always
|
|
// want to show the notification AND the logic below was directly
|
|
// ported from "legacy" GTK so this is fine, but I want to leave this
|
|
// note so we can do it one day.
|
|
|
|
// Set a default title if we don't already have one
|
|
const t = switch (n.title.len) {
|
|
0 => "Ghostty",
|
|
else => n.title,
|
|
};
|
|
|
|
const notification = gio.Notification.new(t);
|
|
defer notification.unref();
|
|
notification.setBody(n.body);
|
|
|
|
const icon = gio.ThemedIcon.new("com.mitchellh.ghostty");
|
|
defer icon.unref();
|
|
notification.setIcon(icon.as(gio.Icon));
|
|
|
|
const pointer = glib.Variant.newUint64(switch (target) {
|
|
.app => 0,
|
|
.surface => |v| @intFromPtr(v),
|
|
});
|
|
notification.setDefaultActionAndTargetValue(
|
|
"app.present-surface",
|
|
pointer,
|
|
);
|
|
|
|
// We set the notification ID to the body content. If the content is the
|
|
// same, this notification may replace a previous notification
|
|
const gio_app = self.as(gio.Application);
|
|
gio_app.sendNotification(n.body, notification);
|
|
}
|
|
|
|
pub fn equalizeSplits(target: apprt.Target) bool {
|
|
switch (target) {
|
|
.app => {
|
|
log.warn("equalize splits to app is unexpected", .{});
|
|
return false;
|
|
},
|
|
|
|
.surface => |core| {
|
|
const surface = core.rt_surface.surface;
|
|
return surface.as(gtk.Widget).activateAction("split-tree.equalize", null) != 0;
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn gotoSplit(
|
|
target: apprt.Target,
|
|
to: apprt.action.GotoSplit,
|
|
) bool {
|
|
switch (target) {
|
|
.app => return false,
|
|
.surface => |core| {
|
|
// Design note: we can't use widget actions here because
|
|
// we need to know whether there is a goto target for returning
|
|
// the proper perform result (boolean).
|
|
|
|
const surface = core.rt_surface.surface;
|
|
const tree = ext.getAncestor(
|
|
SplitTree,
|
|
surface.as(gtk.Widget),
|
|
) orelse {
|
|
log.warn("surface is not in a split tree, ignoring goto_split", .{});
|
|
return false;
|
|
};
|
|
|
|
return tree.goto(switch (to) {
|
|
.previous => .previous_wrapped,
|
|
.next => .next_wrapped,
|
|
.up => .{ .spatial = .up },
|
|
.down => .{ .spatial = .down },
|
|
.left => .{ .spatial = .left },
|
|
.right => .{ .spatial = .right },
|
|
});
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn gotoTab(
|
|
target: apprt.Target,
|
|
tab: apprt.action.GotoTab,
|
|
) bool {
|
|
switch (target) {
|
|
.app => return false,
|
|
.surface => |core| {
|
|
const surface = core.rt_surface.surface;
|
|
const window = ext.getAncestor(
|
|
Window,
|
|
surface.as(gtk.Widget),
|
|
) orelse {
|
|
log.warn("surface is not in a window, ignoring new_tab", .{});
|
|
return false;
|
|
};
|
|
|
|
return window.selectTab(switch (tab) {
|
|
.previous => .previous,
|
|
.next => .next,
|
|
.last => .last,
|
|
else => .{ .n = @intCast(@intFromEnum(tab)) },
|
|
});
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn initialSize(
|
|
target: apprt.Target,
|
|
value: apprt.action.InitialSize,
|
|
) bool {
|
|
switch (target) {
|
|
.app => return false,
|
|
.surface => |core| {
|
|
const surface = core.rt_surface.surface;
|
|
surface.setDefaultSize(.{
|
|
.width = value.width,
|
|
.height = value.height,
|
|
});
|
|
return true;
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn mouseOverLink(
|
|
target: apprt.Target,
|
|
value: apprt.action.MouseOverLink,
|
|
) void {
|
|
switch (target) {
|
|
.app => log.warn("mouse over link to app is unexpected", .{}),
|
|
.surface => |surface| surface.rt_surface.gobj().setMouseHoverUrl(
|
|
if (value.url.len > 0) value.url else null,
|
|
),
|
|
}
|
|
}
|
|
|
|
pub fn mouseShape(
|
|
target: apprt.Target,
|
|
shape: terminal.MouseShape,
|
|
) void {
|
|
switch (target) {
|
|
.app => log.warn("mouse shape to app is unexpected", .{}),
|
|
.surface => |surface| surface.rt_surface.gobj().setMouseShape(shape),
|
|
}
|
|
}
|
|
|
|
pub fn mouseVisibility(
|
|
target: apprt.Target,
|
|
visibility: apprt.action.MouseVisibility,
|
|
) void {
|
|
switch (target) {
|
|
.app => log.warn("mouse visibility to app is unexpected", .{}),
|
|
.surface => |surface| surface.rt_surface.gobj().setMouseHidden(switch (visibility) {
|
|
.visible => false,
|
|
.hidden => true,
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn moveTab(
|
|
target: apprt.Target,
|
|
value: apprt.action.MoveTab,
|
|
) bool {
|
|
switch (target) {
|
|
.app => return false,
|
|
.surface => |core| {
|
|
const surface = core.rt_surface.surface;
|
|
const window = ext.getAncestor(
|
|
Window,
|
|
surface.as(gtk.Widget),
|
|
) orelse {
|
|
log.warn("surface is not in a window, ignoring new_tab", .{});
|
|
return false;
|
|
};
|
|
|
|
return window.moveTab(
|
|
surface,
|
|
@intCast(value.amount),
|
|
);
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn newSplit(
|
|
target: apprt.Target,
|
|
direction: apprt.action.SplitDirection,
|
|
) bool {
|
|
switch (target) {
|
|
.app => {
|
|
log.warn("new split to app is unexpected", .{});
|
|
return false;
|
|
},
|
|
|
|
.surface => |core| {
|
|
const surface = core.rt_surface.surface;
|
|
|
|
return surface.as(gtk.Widget).activateAction(
|
|
"split-tree.new-split",
|
|
"&s",
|
|
@tagName(direction).ptr,
|
|
) != 0;
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn newTab(target: apprt.Target) bool {
|
|
switch (target) {
|
|
.app => {
|
|
log.warn("new tab to app is unexpected", .{});
|
|
return false;
|
|
},
|
|
|
|
.surface => |core| {
|
|
// Get the window ancestor of the surface. Surfaces shouldn't
|
|
// be aware they might be in windows but at the app level we
|
|
// can do this.
|
|
const surface = core.rt_surface.surface;
|
|
const window = ext.getAncestor(
|
|
Window,
|
|
surface.as(gtk.Widget),
|
|
) orelse {
|
|
log.warn("surface is not in a window, ignoring new_tab", .{});
|
|
return false;
|
|
};
|
|
window.newTab(core);
|
|
return true;
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn newWindow(
|
|
self: *Application,
|
|
parent: ?*CoreSurface,
|
|
) !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
|
|
// necessary, but I don't want to risk a bug where on a slow machine
|
|
// or something we quit immediately after starting up because there
|
|
// was a delay in the event loop before we created a Window.
|
|
self.private().requested_window = true;
|
|
|
|
const win = Window.new(self);
|
|
initAndShowWindow(self, win, parent);
|
|
}
|
|
|
|
fn initAndShowWindow(
|
|
self: *Application,
|
|
win: *Window,
|
|
parent: ?*CoreSurface,
|
|
) 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
|
|
// of sync with the application config.
|
|
_ = gobject.Object.bindProperty(
|
|
self.as(gobject.Object),
|
|
"config",
|
|
win.as(gobject.Object),
|
|
"config",
|
|
.{},
|
|
);
|
|
|
|
// Create a new tab
|
|
win.newTab(parent);
|
|
|
|
// Show the window
|
|
gtk.Window.present(win.as(gtk.Window));
|
|
}
|
|
|
|
pub fn openConfig(self: *Application) bool {
|
|
// Get the config file path
|
|
const alloc = self.allocator();
|
|
const path = configpkg.edit.openPath(alloc) catch |err| {
|
|
log.warn("error getting config file path: {}", .{err});
|
|
return false;
|
|
};
|
|
defer alloc.free(path);
|
|
|
|
// Open it using openURL. "path" isn't actually a URL but
|
|
// at the time of writing that works just fine for GTK.
|
|
openUrl(self, .{ .kind = .text, .url = path });
|
|
return true;
|
|
}
|
|
|
|
pub fn openUrl(
|
|
self: *Application,
|
|
value: apprt.action.OpenUrl,
|
|
) void {
|
|
// TODO: use https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.OpenURI.html
|
|
|
|
// Fallback to the minimal cross-platform way of opening a URL.
|
|
// This is always a safe fallback and enables for example Windows
|
|
// to open URLs (GTK on Windows via WSL is a thing).
|
|
internal_os.open(
|
|
self.allocator(),
|
|
value.kind,
|
|
value.url,
|
|
) catch |err| log.warn("unable to open url: {}", .{err});
|
|
}
|
|
|
|
pub fn pwd(
|
|
target: apprt.Target,
|
|
value: apprt.action.Pwd,
|
|
) void {
|
|
switch (target) {
|
|
.app => log.warn("pwd to app is unexpected", .{}),
|
|
.surface => |surface| surface.rt_surface.gobj().setPwd(value.pwd),
|
|
}
|
|
}
|
|
|
|
pub fn quitTimer(
|
|
self: *Application,
|
|
mode: apprt.action.QuitTimer,
|
|
) !void {
|
|
switch (mode) {
|
|
.start => self.startQuitTimer(),
|
|
.stop => self.stopQuitTimer(),
|
|
}
|
|
}
|
|
|
|
pub fn presentTerminal(
|
|
target: apprt.Target,
|
|
) bool {
|
|
return switch (target) {
|
|
.app => false,
|
|
.surface => |v| surface: {
|
|
v.rt_surface.surface.present();
|
|
break :surface true;
|
|
},
|
|
};
|
|
}
|
|
|
|
pub fn progressReport(
|
|
target: apprt.Target,
|
|
value: terminal.osc.Command.ProgressReport,
|
|
) bool {
|
|
return switch (target) {
|
|
.app => false,
|
|
.surface => |v| surface: {
|
|
v.rt_surface.surface.setProgressReport(value);
|
|
break :surface true;
|
|
},
|
|
};
|
|
}
|
|
|
|
pub fn promptTitle(target: apprt.Target) bool {
|
|
switch (target) {
|
|
.app => return false,
|
|
.surface => |v| {
|
|
v.rt_surface.surface.promptTitle();
|
|
return true;
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Reload the configuration for the application and propagate it
|
|
/// across the entire application and all terminals.
|
|
pub fn reloadConfig(
|
|
self: *Application,
|
|
target: apprt.Target,
|
|
opts: apprt.action.ReloadConfig,
|
|
) !void {
|
|
// Tell systemd that reloading has started.
|
|
systemd.notify.reloading();
|
|
|
|
// When we exit this function tell systemd that reloading has finished.
|
|
defer systemd.notify.ready();
|
|
|
|
// Get our config object.
|
|
const config: *Config = config: {
|
|
// Soft-reloading applies conditional logic to the existing loaded
|
|
// config so we return that as-is (but take a reference).
|
|
if (opts.soft) {
|
|
break :config self.private().config.ref();
|
|
}
|
|
|
|
// Hard reload, load a new config completely.
|
|
const alloc = self.allocator();
|
|
var config = try CoreConfig.load(alloc);
|
|
defer config.deinit();
|
|
break :config try .new(alloc, &config);
|
|
};
|
|
defer config.unref();
|
|
|
|
// Update the proper target. This will trigger a `confige_change`
|
|
// apprt action which will propagate the config properly to our
|
|
// property system.
|
|
switch (target) {
|
|
.app => try self.core().updateConfig(
|
|
self.rt(),
|
|
config.get(),
|
|
),
|
|
.surface => |core| try core.updateConfig(config.get()),
|
|
}
|
|
}
|
|
|
|
pub fn render(target: apprt.Target) void {
|
|
switch (target) {
|
|
.app => {},
|
|
.surface => |v| v.rt_surface.surface.redraw(),
|
|
}
|
|
}
|
|
|
|
pub fn resizeSplit(
|
|
target: apprt.Target,
|
|
value: apprt.action.ResizeSplit,
|
|
) bool {
|
|
switch (target) {
|
|
.app => {
|
|
log.warn("resize_split to app is unexpected", .{});
|
|
return false;
|
|
},
|
|
.surface => |core| {
|
|
const surface = core.rt_surface.surface;
|
|
const tree = ext.getAncestor(
|
|
SplitTree,
|
|
surface.as(gtk.Widget),
|
|
) orelse {
|
|
log.warn("surface is not in a split tree, ignoring goto_split", .{});
|
|
return false;
|
|
};
|
|
|
|
return tree.resize(
|
|
switch (value.direction) {
|
|
.up => .up,
|
|
.down => .down,
|
|
.left => .left,
|
|
.right => .right,
|
|
},
|
|
value.amount,
|
|
) catch |err| switch (err) {
|
|
error.OutOfMemory => {
|
|
log.warn("unable to resize split, out of memory", .{});
|
|
return false;
|
|
},
|
|
};
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn ringBell(target: apprt.Target) void {
|
|
switch (target) {
|
|
.app => {},
|
|
.surface => |v| v.rt_surface.surface.setBellRinging(true),
|
|
}
|
|
}
|
|
|
|
pub fn setTitle(
|
|
target: apprt.Target,
|
|
value: apprt.action.SetTitle,
|
|
) void {
|
|
switch (target) {
|
|
.app => log.warn("set_title to app is unexpected", .{}),
|
|
.surface => |surface| surface.rt_surface.gobj().setTitle(value.title),
|
|
}
|
|
}
|
|
|
|
pub fn showChildExited(
|
|
target: apprt.Target,
|
|
value: apprt.surface.Message.ChildExited,
|
|
) bool {
|
|
return switch (target) {
|
|
.app => false,
|
|
.surface => |v| v.rt_surface.surface.childExited(value),
|
|
};
|
|
}
|
|
|
|
pub fn showGtkInspector() void {
|
|
gtk.Window.setInteractiveDebugging(@intFromBool(true));
|
|
}
|
|
|
|
pub fn sizeLimit(
|
|
target: apprt.Target,
|
|
value: apprt.action.SizeLimit,
|
|
) bool {
|
|
switch (target) {
|
|
.app => return false,
|
|
.surface => |core| {
|
|
// Note: we ignore the max size currently because we have
|
|
// no mechanism to enforce it.
|
|
const surface = core.rt_surface.surface;
|
|
surface.setMinSize(.{
|
|
.width = value.min_width,
|
|
.height = value.min_height,
|
|
});
|
|
|
|
return true;
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn toggleFullscreen(target: apprt.Target) void {
|
|
switch (target) {
|
|
.app => {},
|
|
.surface => |v| v.rt_surface.surface.toggleFullscreen(),
|
|
}
|
|
}
|
|
|
|
pub fn toggleQuickTerminal(self: *Application) bool {
|
|
// If we already have a quick terminal window, we just toggle the
|
|
// visibility of it.
|
|
if (getQuickTerminalWindow()) |win| {
|
|
win.toggleVisibility();
|
|
return true;
|
|
}
|
|
|
|
// If we don't support quick terminals then we do nothing.
|
|
const priv = self.private();
|
|
if (!priv.winproto.supportsQuickTerminal()) return false;
|
|
|
|
// Create our new window as a quick terminal
|
|
const win = gobject.ext.newInstance(Window, .{
|
|
.application = self,
|
|
.@"quick-terminal" = true,
|
|
});
|
|
assert(win.isQuickTerminal());
|
|
initAndShowWindow(self, win, null);
|
|
return true;
|
|
}
|
|
|
|
pub fn toggleSplitZoom(target: apprt.Target) bool {
|
|
switch (target) {
|
|
.app => {
|
|
log.warn("toggle_split_zoom to app is unexpected", .{});
|
|
return false;
|
|
},
|
|
|
|
.surface => |core| {
|
|
// TODO: pass surface ID when we have that
|
|
const surface = core.rt_surface.surface;
|
|
return surface.as(gtk.Widget).activateAction("split-tree.zoom", null) != 0;
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn showOnScreenKeyboard(target: apprt.Target) bool {
|
|
switch (target) {
|
|
.app => {
|
|
log.warn("show_on_screen_keyboard to app is unexpected", .{});
|
|
return false;
|
|
},
|
|
// NOTE: Even though `activateOsk` takes a gdk.Event, it's currently
|
|
// unused by all implementations of `activateOsk` as of GTK 4.18.
|
|
// The commit that introduced the method (ce6aa73c) clarifies that
|
|
// the event *may* be used by other IM backends, but for Linux desktop
|
|
// environments this doesn't matter.
|
|
.surface => |v| return v.rt_surface.surface.showOnScreenKeyboard(null),
|
|
}
|
|
}
|
|
|
|
fn getQuickTerminalWindow() ?*Window {
|
|
// Find a quick terminal window.
|
|
const list = gtk.Window.listToplevels();
|
|
defer list.free();
|
|
if (ext.listFind(gtk.Window, list, struct {
|
|
fn find(gtk_win: *gtk.Window) bool {
|
|
const win = gobject.ext.cast(
|
|
Window,
|
|
gtk_win,
|
|
) orelse return false;
|
|
return win.isQuickTerminal();
|
|
}
|
|
}.find)) |w| return gobject.ext.cast(
|
|
Window,
|
|
w,
|
|
).?;
|
|
|
|
return null;
|
|
}
|
|
|
|
pub fn toggleMaximize(target: apprt.Target) void {
|
|
switch (target) {
|
|
.app => {},
|
|
.surface => |v| v.rt_surface.surface.toggleMaximize(),
|
|
}
|
|
}
|
|
|
|
pub fn toggleTabOverview(target: apprt.Target) bool {
|
|
switch (target) {
|
|
.app => return false,
|
|
.surface => |core| {
|
|
const surface = core.rt_surface.surface;
|
|
const window = ext.getAncestor(
|
|
Window,
|
|
surface.as(gtk.Widget),
|
|
) orelse {
|
|
log.warn("surface is not in a window, ignoring new_tab", .{});
|
|
return false;
|
|
};
|
|
|
|
window.toggleTabOverview();
|
|
return true;
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn toggleWindowDecorations(target: apprt.Target) bool {
|
|
switch (target) {
|
|
.app => return false,
|
|
.surface => |core| {
|
|
const surface = core.rt_surface.surface;
|
|
const window = ext.getAncestor(
|
|
Window,
|
|
surface.as(gtk.Widget),
|
|
) orelse {
|
|
log.warn("surface is not in a window, ignoring toggle_window_decorations", .{});
|
|
return false;
|
|
};
|
|
|
|
window.toggleWindowDecorations();
|
|
return true;
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn toggleCommandPalette(target: apprt.Target) bool {
|
|
switch (target) {
|
|
.app => return false,
|
|
.surface => |surface| {
|
|
return surface.rt_surface.gobj().toggleCommandPalette();
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn controlInspector(target: apprt.Target, value: apprt.Action.Value(.inspector)) bool {
|
|
switch (target) {
|
|
.app => return false,
|
|
.surface => |surface| {
|
|
return surface.rt_surface.gobj().controlInspector(value);
|
|
},
|
|
}
|
|
}
|
|
};
|
|
|
|
/// This sets various GTK-related environment variables as necessary
|
|
/// given the runtime environment or configuration.
|
|
///
|
|
/// This must be called BEFORE GTK initialization.
|
|
fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void {
|
|
assert(gtk.isInitialized() == 0);
|
|
|
|
var gdk_debug: struct {
|
|
/// output OpenGL debug information
|
|
opengl: bool = false,
|
|
/// disable GLES, Ghostty can't use GLES
|
|
@"gl-disable-gles": bool = false,
|
|
// GTK's new renderer can cause blurry font when using fractional scaling.
|
|
@"gl-no-fractional": bool = false,
|
|
/// Disabling Vulkan can improve startup times by hundreds of
|
|
/// milliseconds on some systems. We don't use Vulkan so we can just
|
|
/// disable it.
|
|
@"vulkan-disable": bool = false,
|
|
} = .{
|
|
.opengl = config.@"gtk-opengl-debug",
|
|
};
|
|
|
|
var gdk_disable: struct {
|
|
@"gles-api": bool = false,
|
|
/// current gtk implementation for color management is not good enough.
|
|
/// see: https://bugs.kde.org/show_bug.cgi?id=495647
|
|
/// gtk issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6864
|
|
@"color-mgmt": bool = true,
|
|
/// Disabling Vulkan can improve startup times by hundreds of
|
|
/// milliseconds on some systems. We don't use Vulkan so we can just
|
|
/// disable it.
|
|
vulkan: bool = false,
|
|
} = .{};
|
|
|
|
environment: {
|
|
if (gtk_version.runtimeAtLeast(4, 18, 0)) {
|
|
gdk_disable.@"color-mgmt" = false;
|
|
}
|
|
|
|
if (gtk_version.runtimeAtLeast(4, 16, 0)) {
|
|
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE.
|
|
// For the remainder of "why" see the 4.14 comment below.
|
|
gdk_disable.@"gles-api" = true;
|
|
gdk_disable.vulkan = true;
|
|
break :environment;
|
|
}
|
|
if (gtk_version.runtimeAtLeast(4, 14, 0)) {
|
|
// We need to export GDK_DEBUG to run on Wayland after GTK 4.14.
|
|
// Older versions of GTK do not support these values so it is safe
|
|
// to always set this. Forwards versions are uncertain so we'll have
|
|
// to reassess...
|
|
//
|
|
// Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589
|
|
gdk_debug.@"gl-disable-gles" = true;
|
|
gdk_debug.@"vulkan-disable" = true;
|
|
|
|
if (gtk_version.runtimeUntil(4, 17, 5)) {
|
|
// Removed at GTK v4.17.5
|
|
gdk_debug.@"gl-no-fractional" = true;
|
|
}
|
|
break :environment;
|
|
}
|
|
|
|
// Versions prior to 4.14 are a bit of an unknown for Ghostty. It
|
|
// is an environment that isn't tested well and we don't have a
|
|
// good understanding of what we may need to do.
|
|
gdk_debug.@"vulkan-disable" = true;
|
|
}
|
|
|
|
{
|
|
var buf: [1024]u8 = undefined;
|
|
var fmt = std.io.fixedBufferStream(&buf);
|
|
const writer = fmt.writer();
|
|
var first: bool = true;
|
|
inline for (@typeInfo(@TypeOf(gdk_debug)).@"struct".fields) |field| {
|
|
if (@field(gdk_debug, field.name)) {
|
|
if (!first) try writer.writeAll(",");
|
|
try writer.writeAll(field.name);
|
|
first = false;
|
|
}
|
|
}
|
|
try writer.writeByte(0);
|
|
const value = fmt.getWritten();
|
|
log.warn("setting GDK_DEBUG={s}", .{value[0 .. value.len - 1]});
|
|
_ = internal_os.setenv("GDK_DEBUG", value[0 .. value.len - 1 :0]);
|
|
}
|
|
|
|
{
|
|
var buf: [1024]u8 = undefined;
|
|
var fmt = std.io.fixedBufferStream(&buf);
|
|
const writer = fmt.writer();
|
|
var first: bool = true;
|
|
inline for (@typeInfo(@TypeOf(gdk_disable)).@"struct".fields) |field| {
|
|
if (@field(gdk_disable, field.name)) {
|
|
if (!first) try writer.writeAll(",");
|
|
try writer.writeAll(field.name);
|
|
first = false;
|
|
}
|
|
}
|
|
try writer.writeByte(0);
|
|
const value = fmt.getWritten();
|
|
log.warn("setting GDK_DISABLE={s}", .{value[0 .. value.len - 1]});
|
|
_ = internal_os.setenv("GDK_DISABLE", value[0 .. value.len - 1 :0]);
|
|
}
|
|
}
|
|
|
|
fn findActiveWindow(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.c) c_int {
|
|
const window: *gtk.Window = @ptrCast(@alignCast(@constCast(data orelse return -1)));
|
|
|
|
// Confusingly, `isActive` returns 1 when active,
|
|
// but we want to return 0 to indicate equality.
|
|
// Abusing integers to be enums and booleans is a terrible idea, C.
|
|
return if (window.isActive() != 0) 0 else -1;
|
|
}
|
|
|
|
fn loadCssProviderFromData(provider: *gtk.CssProvider, data: [:0]const u8) void {
|
|
assert(gtk_version.runtimeAtLeast(4, 12, 0));
|
|
provider.loadFromString(data);
|
|
}
|