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

Fixes #8495 We were incorrectly calling graceful quit under must quit scenarios. This would do things like confirm quit by inspecting for running processes. However, must quit scenarios (namely when all windows are destroyed) should quit immediately without any checks because the dispose process takes more event loop ticks to fully finish.
2514 lines
86 KiB
Zig
2514 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,
|
|
.desktop => switch (config.@"launched-from".?) {
|
|
.desktop, .systemd, .dbus => true,
|
|
.cli => false,
|
|
},
|
|
};
|
|
|
|
// 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). An initial window will not be immediately created if we were
|
|
// launched by D-Bus activation or systemd. D-Bus activation will send it's
|
|
// own `activate` or `new-window` signal later.
|
|
//
|
|
// 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") switch (config.@"launched-from".?) {
|
|
.desktop, .cli => self.as(gio.Application).activate(),
|
|
.dbus, .systemd => {},
|
|
};
|
|
}
|
|
|
|
// If we are NOT the primary instance, then we never want to run.
|
|
// This means that another instance of the GTK app is running and
|
|
// our "activate" call above will open a window.
|
|
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);
|
|
}
|