diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/apprt/gtk/class/application.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/apprt/gtk/class/application.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/apprt/gtk/class/application.zig | 2945 |
1 files changed, 2945 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/application.zig b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig new file mode 100644 index 00000000..873674ce --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig @@ -0,0 +1,2945 @@ +const std = @import("std"); +const assert = @import("../../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +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 build_info = @import("../build/info.zig"); +const state = &@import("../../../global.zig").state; +const i18n = @import("../../../os/main.zig").i18n; +const apprt = @import("../../../apprt.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 lib = @import("../../../lib/main.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 Tab = @import("tab.zig").Tab; +const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog; +const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts; +const OpenURI = @import("../portal.zig").OpenURI; + +const log = std.log.scoped(.gtk_ghostty_application); + +/// Function used to funnel GLib/GObject/GTK log messages into Zig's logging +/// system rather than just getting dumped directly to stderr. +fn glibLogWriterFunction( + level: glib.LogLevelFlags, + fields: [*]const glib.LogField, + n_fields: usize, + _: ?*anyopaque, +) callconv(.c) glib.LogWriterOutput { + const glib_log = std.log.scoped(.glib); + + var message_: ?[]const u8 = null; + var domain_: ?[]const u8 = null; + for (0..n_fields) |i| { + const field = fields[i]; + const k = std.mem.span(field.f_key orelse continue); + const v: []const u8 = v: { + if (field.f_length >= 0) { + const v: [*]const u8 = @ptrCast(field.f_value orelse continue); + break :v v[0..@intCast(field.f_length)]; + } + const v: [*:0]const u8 = @ptrCast(field.f_value orelse continue); + break :v std.mem.span(v); + }; + if (std.mem.eql(u8, k, "MESSAGE")) { + message_ = v; + continue; + } + if (std.mem.eql(u8, k, "GLIB_DOMAIN")) { + domain_ = v; + continue; + } + } + + const message = message_ orelse return .unhandled; + const domain = domain_ orelse "«unknown»"; + + if (level.level_error) { + glib_log.err("ERROR: {s}: {s}", .{ domain, message }); + return .handled; + } + if (level.level_critical) { + glib_log.err("CRITICAL: {s}: {s}", .{ domain, message }); + return .handled; + } + if (level.level_warning) { + glib_log.warn("WARNING: {s}: {s}", .{ domain, message }); + return .handled; + } + if (level.level_message) { + glib_log.info("MESSAGE: {s}: {s}", .{ domain, message }); + return .handled; + } + if (level.level_info) { + glib_log.info("INFO: {s}: {s}", .{ domain, message }); + return .handled; + } + if (level.level_debug) { + glib_log.debug("DEBUG: {s}: {s}", .{ domain, message }); + return .handled; + } + glib_log.debug("UNKNOWN: {s}: {s}", .{ domain, message }); + return .handled; +} + +/// 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, + + /// 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, + + /// A copy of the LANG environment variable that was provided to Ghostty + /// by the system. If this is null, the LANG environment variable did + /// not exist in Ghostty's environment variable. + saved_language: ?[:0]const u8 = null, + + open_uri: OpenURI = undefined, + + 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; + + // Capture GLib/GObject/GTK log messages and funnel them through Zig's + // logging system rather than just getting dumped directly to stderr. + _ = glib.logSetWriterFunc(glibLogWriterFunction, null, null); + + // Log our GTK versions + gtk_version.logVersion(); + adw_version.logVersion(); + + // 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(); + + const saved_language: ?[:0]const u8 = saved_language: { + const old_language = old_language: { + const result = (internal_os.getenv(alloc, "LANG") catch break :old_language null) orelse break :old_language null; + defer result.deinit(alloc); + break :old_language alloc.dupeZ(u8, result.value) catch break :old_language null; + }; + + if (config.language) |language| _ = internal_os.setenv("LANG", language); + + break :saved_language old_language; + }; + + // 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}); + }; + + // 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 build_info.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(); + 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 = build_info.resource_path, + }); + + // 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: *Private = 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, .{}), + .saved_language = saved_language, + .open_uri = .init(rt_app), + }; + + // Signals + _ = gobject.Object.signals.notify.connect( + self, + *Self, + propConfig, + self, + .{ .detail = "config" }, + ); + + _ = gtk.CssProvider.signals.parsing_error.connect( + css_provider, + *Self, + signalCssParsingError, + self, + .{}, + ); + + // 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: *Private = self.private(); + priv.config.unref(); + priv.winproto.deinit(); + priv.open_uri.deinit(); + priv.global_shortcuts.unref(); + if (priv.saved_language) |language| alloc.free(language); + 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; + } + + /// Get the original language that Ghostty was launched with. This returns a + /// pointer to internal memory so it must be copied by callers. + pub fn savedLanguage(self: *Self) ?[:0]const u8 { + return self.private().saved_language; + } + + /// 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) { + log.debug("must_quit due to quit timer expired", .{}); + break :q true; + } + + // If we have no windows attached to our app, also quit. + // We only do this if we don't have the closed delay set, + // because with the closed delay set we'll exit eventually. + if (config.@"quit-after-last-window-closed-delay" == null) { + if (priv.requested_window and @as( + ?*glib.List, + self.as(gtk.Application).getWindows(), + ) == null) { + log.debug("must_quit due to no app windows", .{}); + 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), + + .copy_title_to_clipboard => return Action.copyTitleToClipboard(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_window => return Action.gotoWindow(value), + + .goto_tab => return Action.gotoTab(target, value), + + .initial_size => return Action.initialSize(target, value), + + .inspector => return Action.controlInspector(target, value), + + .key_sequence => return Action.keySequence(target, value), + .key_table => return Action.keyTable(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, + }, + .none, + ), + + .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, value), + + .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), + + .scrollbar => Action.scrollbar(target, value), + + .set_title => Action.setTitle(target, value), + .set_tab_title => return Action.setTabTitle(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), + .command_finished => return Action.commandFinished(target, value), + .readonly => return Action.setReadonly(target, value), + + .start_search => Action.startSearch(target, value), + .end_search => Action.endSearch(target), + .search_total => Action.searchTotal(target, value), + .search_selected => Action.searchSelected(target, value), + + // Unimplemented + .secure_input, + .close_all_windows, + .float_window, + .toggle_visibility, + .toggle_background_opacity, + .cell_size, + .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 open URI portal implementation. + pub fn openUri(self: *Self) *OpenURI { + return &self.private().open_uri; + } + + /// 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 || std.Io.Writer.Error)!void { + const alloc = self.allocator(); + const priv: *Private = self.private(); + const config = priv.config.get(); + + var buf: std.Io.Writer.Allocating = try .initCapacity(alloc, 2048); + defer buf.deinit(); + + const writer = &buf.writer; + + // Load standard css first as it can override some of the user configured styling. + try loadRuntimeCss414(config, writer); + try loadRuntimeCss416(config, writer); + + 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 }); + } + + const contents = buf.written(); + + log.debug("runtime CSS is {d} bytes", .{contents.len}); + + const bytes = glib.Bytes.new(contents.ptr, contents.len); + defer bytes.unref(); + + // Clears any previously loaded CSS from this provider + priv.css_provider.loadFromBytes(bytes); + } + + /// Load runtime CSS for older than GTK 4.16 + fn loadRuntimeCss414( + config: *const CoreConfig, + writer: *std.Io.Writer, + ) std.Io.Writer.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: *std.Io.Writer, + ) std.Io.Writer.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) (std.fs.File.ReadError || Allocator.Error)!void { + const priv: *Private = 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.get(); + 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(); + + const css_file_size_limit = 5 * 1024 * 1024; // 5MB + + log.info("loading gtk-custom-css path={s}", .{path}); + const contents = file.readToEndAlloc( + alloc, + css_file_size_limit, + ) catch |err| switch (err) { + error.FileTooBig => { + log.warn("gtk-custom-css file {s} was larger than {Bi}", .{ path, css_file_size_limit }); + continue; + }, + else => |e| return e, + }; + defer alloc.free(contents); + + const bytes = glib.Bytes.new(contents.ptr, contents.len); + defer bytes.unref(); + + const css_provider = gtk.CssProvider.new(); + errdefer css_provider.unref(); + + _ = gtk.CssProvider.signals.parsing_error.connect( + css_provider, + *Self, + signalCssParsingError, + self, + .{}, + ); + + try priv.custom_css_providers.append(alloc, css_provider); + + css_provider.loadFromBytes(bytes); + + gtk.StyleContext.addProviderForDisplay( + display, + css_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 = .mixed }); + 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.WriteFailed => { + log.warn("accelerator somehow longer than 1024 bytes: {f}", .{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.WriteFailed, 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}, + ); + }; + } + + /// Log CSS parsing error + fn signalCssParsingError( + _: *gtk.CssProvider, + css_section: *gtk.CssSection, + err: *glib.Error, + _: *Self, + ) callconv(.c) void { + const location = css_section.toString(); + defer glib.free(location); + if (comptime gtk_version.atLeast(4, 16, 0)) bytes: { + const bytes = css_section.getBytes() orelse break :bytes; + var len: usize = undefined; + const ptr = bytes.getData(&len) orelse break :bytes; + const data = ptr[0..len]; + log.warn("css parsing failed at {s}: {s} {d} {s}\n{s}", .{ + location, + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "«unknown»", + data, + }); + return; + } + log.warn("css parsing failed at {s}: {s} {d} {s}", .{ + location, + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "«unknown»", + }); + } + + //--------------------------------------------------------------- + // 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)); + + // The D-Bus connection is only valid after GApplication startup. + self.openUri().setDbusConnection( + self.as(gio.Application).getDbusConnection(), + ); + + // 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(); + + // 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, + .{}, + ); + } + + 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: *Private = 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}, + ); + }; + } + + if (gtk_version.atLeast(4, 20, 0)) { + const gtk_scheme: gtk.InterfaceColorScheme = switch (scheme) { + .light => gtk.InterfaceColorScheme.light, + .dark => gtk.InterfaceColorScheme.dark, + }; + var value = gobject.ext.Value.newFrom(gtk_scheme); + gobject.Object.setProperty( + priv.css_provider.as(gobject.Object), + "prefers-color-scheme", + &value, + ); + for (priv.custom_css_providers.items) |css_provider| { + gobject.Object.setProperty( + css_provider.as(gobject.Object), + "prefers-color-scheme", + &value, + ); + } + } + } + + 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", .{}); + + var arena: std.heap.ArenaAllocator = .init(Application.default().allocator()); + defer arena.deinit(); + + const alloc = arena.allocator(); + + var working_directory: ?[:0]const u8 = null; + var title: ?[:0]const u8 = null; + var command: ?configpkg.Command = null; + var args: std.ArrayList([:0]const u8) = .empty; + + overrides: { + // were we given a parameter? + const parameter = parameter_ orelse break :overrides; + + 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}', not '{s}'", .{ + parameter.getTypeString(), + as_variant_type.peekString()[0..as_variant_type.getStringLength()], + }); + break :overrides; + } + + const s_variant_type = glib.VariantType.new("s"); + defer s_variant_type.free(); + + var it: glib.VariantIter = undefined; + _ = it.init(parameter); + + var e_seen: bool = false; + var i: usize = 0; + + while (it.nextValue()) |value| : (i += 1) { + 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 argument: {d} {s}", .{ i, str }); + + if (e_seen) { + const duplicated = alloc.dupeZ(u8, str) catch |err| { + log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err }); + break :overrides; + }; + args.append(alloc, duplicated) catch |err| { + log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); + break :overrides; + }; + continue; + } + + if (std.mem.eql(u8, str, "-e")) { + e_seen = true; + continue; + } + + if (lib.cutPrefix(u8, str, "--command=")) |v| { + var cmd: configpkg.Command = undefined; + cmd.parseCLI(alloc, v) catch |err| { + log.warn("unable to parse command: {t}", .{err}); + continue; + }; + command = cmd; + continue; + } + if (lib.cutPrefix(u8, str, "--working-directory=")) |v| { + working_directory = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| wd: { + log.warn("unable to duplicate working directory: {t}", .{err}); + break :wd null; + }; + continue; + } + if (lib.cutPrefix(u8, str, "--title=")) |v| { + title = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| t: { + log.warn("unable to duplicate title: {t}", .{err}); + break :t null; + }; + continue; + } + } + } + + if (args.items.len > 0) { + command = .{ + .direct = args.items, + }; + } + + Action.newWindow(self, null, .{ + .command = command, + .working_directory = working_directory, + .title = title, + }) catch |err| { + log.warn("unable to create new window: {t}", .{err}); + }; + } + + 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 received a u64 from the system. + if (glib.Variant.isOfType(parameter, t) == 0) { + return; + } + + // Convert the u64 to a core surface by using it as a surface ID. + // A value of zero means that there was no target surface for the + // notification so we don't focus any surface. + const surface_id = parameter.getUint64(); + if (surface_id == 0) return; + const surface = self.core().findSurfaceByID(surface_id) orelse return; + + _ = 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); + } + }; + + pub fn openUrlFallback(self: *Application, kind: apprt.action.OpenUrl.Kind, url: []const u8) void { + // 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(), + kind, + url, + ) catch |err| log.warn("unable to open url: {}", .{err}); + } +}; + +/// 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 copyTitleToClipboard(target: apprt.Target) bool { + return switch (target) { + .app => false, + .surface => |v| v.rt_surface.gobj().copyTitleToClipboard(), + }; + } + + 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 { + switch (target) { + .app => {}, + .surface => |v| { + v.rt_surface.gobj().sendDesktopNotification(n.title, n.body); + return; + }, + } + + // 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)); + notification.setDefaultActionAndTargetValue( + "app.present-surface", + glib.Variant.newUint64(0), + ); + + // 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 gotoWindow(direction: apprt.action.GotoWindow) bool { + const glist = gtk.Window.listToplevels(); + defer glist.free(); + + // The window we're starting from is typically our active window. + const starting: *glib.List = @as(?*glib.List, glist.findCustom( + null, + findActiveWindow, + )) orelse glist; + + // Go forward or backwards in the list until we find a valid + // window that is visible. + var current_: ?*glib.List = starting; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; + } + + // If we reached here, we didn't find a valid window to focus. + // Wrap around. + current_ = switch (direction) { + .next => glist, + .previous => last: { + var end: *glib.List = glist; + while (end.f_next) |next| end = next; + break :last end; + }, + }; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + if (current_ == starting) break; + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; + } + + return false; + } + + fn gotoWindowMaybe(gtk_window: *gtk.Window) bool { + // If it is already active skip it. + if (gtk_window.isActive() != 0) return false; + // If it is hidden, skip it. + if (gtk_window.as(gtk.Widget).isVisible() == 0) return false; + // If it isn't a Ghostty window, skip it. + const window = gobject.ext.cast( + Window, + gtk_window, + ) orelse return false; + + // Focus our active surface + const surface = window.getActiveSurface() orelse return false; + gtk.Window.present(gtk_window); + surface.grabFocus(); + return true; + } + + 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, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) !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, .{ + .title = overrides.title, + }); + initAndShowWindow( + self, + win, + parent, + .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }, + ); + } + + fn initAndShowWindow( + self: *Application, + win: *Window, + parent: ?*CoreSurface, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) 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 with window context (first tab in new window) + win.newTabForWindow(parent, .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }); + + // Estimate the initial window size before presenting so the window + // manager can position it correctly. + if (win.getActiveSurface()) |surface| { + surface.estimateInitialSize(); + if (surface.getDefaultSize()) |size| { + win.as(gtk.Window).setDefaultSize( + @intCast(size.width), + @intCast(size.height), + ); + } + } + + // 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 { + if (std.mem.startsWith(u8, value.url, "/")) { + self.openUrlFallback(value.kind, value.url); + return; + } + if (std.mem.startsWith(u8, value.url, "file://")) { + self.openUrlFallback(value.kind, value.url); + return; + } + + self.openUri().start(value) catch |err| { + log.err("unable to open uri err={}", .{err}); + self.openUrlFallback(value.kind, value.url); + return; + }; + } + + 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, value: apprt.action.PromptTitle) bool { + switch (value) { + .surface => switch (target) { + .app => return false, + .surface => |v| { + v.rt_surface.surface.promptTitle(); + return true; + }, + }, + .tab => { + switch (target) { + .app => return false, + .surface => |v| { + const surface = v.rt_surface.surface; + const tab = ext.getAncestor( + Tab, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a tab, ignoring prompt_tab_title", .{}); + return false; + }; + tab.promptTabTitle(); + 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 `config_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 resize_split", .{}); + return false; + }; + + // If the tree has no splits (only one leaf), this action is not performable. + // This allows the key event to pass through to the terminal. + if (!tree.getIsSplit()) 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 scrollbar( + target: apprt.Target, + value: apprt.Action.Value(.scrollbar), + ) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setScrollbar(value), + } + } + + pub fn startSearch(target: apprt.Target, value: apprt.action.StartSearch) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchActive(true, value.needle), + } + } + + pub fn endSearch(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchActive(false, ""), + } + } + + pub fn searchTotal(target: apprt.Target, value: apprt.action.SearchTotal) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchTotal(value.total), + } + } + + pub fn searchSelected(target: apprt.Target, value: apprt.action.SearchSelected) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchSelected(value.selected), + } + } + + 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 setTabTitle( + target: apprt.Target, + value: apprt.action.SetTitle, + ) bool { + switch (target) { + .app => { + log.warn("set_tab_title to app is unexpected", .{}); + return false; + }, + .surface => |core| { + const surface = core.rt_surface.surface; + const tab = ext.getAncestor( + Tab, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a tab, ignoring set_tab_title", .{}); + return false; + }; + tab.setTitleOverride(if (value.title.len == 0) null else value.title); + return true; + }, + } + } + + 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, .none); + 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; + const tree = ext.getAncestor( + SplitTree, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a split tree, ignoring toggle_split_zoom", .{}); + return false; + }; + + // If the tree has no splits (only one leaf), this action is not performable. + // This allows the key event to pass through to the terminal. + if (!tree.getIsSplit()) return false; + + 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); + }, + } + } + + pub fn commandFinished(target: apprt.Target, value: apprt.Action.Value(.command_finished)) bool { + switch (target) { + .app => return false, + .surface => |surface| { + return surface.rt_surface.gobj().commandFinished(value); + }, + } + } + + pub fn setReadonly(target: apprt.Target, value: apprt.Action.Value(.readonly)) bool { + switch (target) { + .app => return false, + .surface => |surface| { + return surface.rt_surface.gobj().setReadonly(value); + }, + } + } + + pub fn keySequence(target: apprt.Target, value: apprt.Action.Value(.key_sequence)) bool { + switch (target) { + .app => { + log.warn("key_sequence action to app is unexpected", .{}); + return false; + }, + .surface => |core| { + core.rt_surface.gobj().keySequenceAction(value) catch |err| { + log.warn("error handling key_sequence action: {}", .{err}); + }; + return true; + }, + } + } + + pub fn keyTable(target: apprt.Target, value: apprt.Action.Value(.key_table)) bool { + switch (target) { + .app => { + log.warn("key_table action to app is unexpected", .{}); + return false; + }, + .surface => |core| { + core.rt_surface.gobj().keyTableAction(value) catch |err| { + log.warn("error handling key_table action: {}", .{err}); + }; + return true; + }, + } + } +}; + +/// 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, + } = .{ + // `gtk-opengl-debug` dumps logs directly to stderr so both must be true + // to enable OpenGL debugging. + .opengl = state.logging.stderr and 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; +} |
