aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/apprt/gtk/class
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/apprt/gtk/class
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/apprt/gtk/class')
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/application.zig2945
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig352
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/close_confirmation_dialog.zig204
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig789
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/config.zig175
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/config_errors_dialog.zig161
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/debug_warning.zig50
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/dialog.zig91
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig633
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig551
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/inspector_widget.zig245
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/inspector_window.zig217
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/key_state_overlay.zig342
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/resize_overlay.zig338
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/search_overlay.zig493
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig1290
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/surface.zig4049
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig264
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig228
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/tab.zig592
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig234
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/window.zig2111
22 files changed, 16354 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;
+}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig
new file mode 100644
index 00000000..d44d38a3
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig
@@ -0,0 +1,352 @@
+const std = @import("std");
+const adw = @import("adw");
+const glib = @import("glib");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const apprt = @import("../../../apprt.zig");
+const gresource = @import("../build/gresource.zig");
+const i18n = @import("../../../os/main.zig").i18n;
+const adw_version = @import("../adw_version.zig");
+const Common = @import("../class.zig").Common;
+const Dialog = @import("dialog.zig").Dialog;
+
+const log = std.log.scoped(.gtk_ghostty_clipboard_confirmation);
+
+/// Whether we're able to have the remember switch
+const can_remember = adw_version.supportsSwitchRow();
+
+pub const ClipboardConfirmationDialog = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = Dialog;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyClipboardConfirmationDialog",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ pub const @"can-remember" = struct {
+ pub const name = "can-remember";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "can_remember",
+ ),
+ },
+ );
+ };
+
+ pub const request = struct {
+ pub const name = "request";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*apprt.ClipboardRequest,
+ .{
+ .accessor = C.privateBoxedFieldAccessor("request"),
+ },
+ );
+ };
+
+ pub const @"clipboard-contents" = struct {
+ pub const name = "clipboard-contents";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*gtk.TextBuffer,
+ .{
+ .accessor = C.privateObjFieldAccessor("clipboard_contents"),
+ },
+ );
+ };
+
+ pub const blur = struct {
+ pub const name = "blur";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "blur",
+ ),
+ },
+ );
+ };
+ };
+
+ pub const signals = struct {
+ pub const deny = struct {
+ pub const name = "deny";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{bool},
+ void,
+ );
+ };
+
+ pub const confirm = struct {
+ pub const name = "confirm";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{bool},
+ void,
+ );
+ };
+ };
+
+ const Private = struct {
+ /// The request that this dialog is for.
+ request: ?*apprt.ClipboardRequest = null,
+
+ /// The clipboard contents being read/written.
+ clipboard_contents: ?*gtk.TextBuffer = null,
+
+ /// Whether the contents should be blurred.
+ blur: bool = false,
+
+ /// Whether the user can remember the choice.
+ can_remember: bool = false,
+
+ // Template bindings
+ text_view_scroll: *gtk.ScrolledWindow,
+ text_view: *gtk.TextView,
+ reveal_button: *gtk.Button,
+ hide_button: *gtk.Button,
+ remember_choice: if (can_remember) *adw.SwitchRow else void,
+
+ pub var offset: c_int = 0;
+ };
+
+ pub fn new() *Self {
+ return gobject.ext.newInstance(Self, .{});
+ }
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+
+ // Trigger initial values
+ self.propBlur(undefined, null);
+ self.propRequest(undefined, null);
+ }
+
+ pub fn present(self: *Self, parent: ?*gtk.Widget) void {
+ self.as(Dialog).present(parent);
+ }
+
+ /// Get the clipboard request without copying.
+ pub fn getRequest(self: *Self) ?*apprt.ClipboardRequest {
+ return self.private().request;
+ }
+
+ /// Get the clipboard contents without copying.
+ pub fn getClipboardContents(self: *Self) ?*gtk.TextBuffer {
+ return self.private().clipboard_contents;
+ }
+
+ //---------------------------------------------------------------
+ // Signal Handlers
+
+ fn propBlur(
+ self: *Self,
+ _: *gobject.ParamSpec,
+ _: ?*anyopaque,
+ ) callconv(.c) void {
+ const priv = self.private();
+ if (priv.blur) {
+ priv.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(false));
+ priv.text_view.as(gtk.Widget).addCssClass("blurred");
+ priv.reveal_button.as(gtk.Widget).setVisible(@intFromBool(true));
+ priv.hide_button.as(gtk.Widget).setVisible(@intFromBool(false));
+ } else {
+ priv.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true));
+ priv.text_view.as(gtk.Widget).removeCssClass("blurred");
+ priv.reveal_button.as(gtk.Widget).setVisible(@intFromBool(false));
+ priv.hide_button.as(gtk.Widget).setVisible(@intFromBool(false));
+ }
+ }
+
+ fn propRequest(
+ self: *Self,
+ _: *gobject.ParamSpec,
+ _: ?*anyopaque,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const req = priv.request orelse return;
+ switch (req.*) {
+ .osc_52_write => {
+ self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access"));
+ self.as(Dialog.Parent).setBody(i18n._("An application is attempting to write to the clipboard. The current clipboard contents are shown below."));
+ },
+ .osc_52_read => {
+ self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access"));
+ self.as(Dialog.Parent).setBody(i18n._("An application is attempting to read from the clipboard. The current clipboard contents are shown below."));
+ },
+ .paste => {
+ self.as(Dialog.Parent).setHeading(i18n._("Warning: Potentially Unsafe Paste"));
+ self.as(Dialog.Parent).setBody(i18n._("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed."));
+ },
+ }
+ }
+
+ fn revealButtonClicked(_: *gtk.Button, self: *Self) callconv(.c) void {
+ const priv = self.private();
+ priv.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true));
+ priv.text_view.as(gtk.Widget).removeCssClass("blurred");
+ priv.hide_button.as(gtk.Widget).setVisible(@intFromBool(true));
+ priv.reveal_button.as(gtk.Widget).setVisible(@intFromBool(false));
+ }
+
+ fn hideButtonClicked(_: *gtk.Button, self: *Self) callconv(.c) void {
+ const priv = self.private();
+ priv.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(false));
+ priv.text_view.as(gtk.Widget).addCssClass("blurred");
+ priv.hide_button.as(gtk.Widget).setVisible(@intFromBool(false));
+ priv.reveal_button.as(gtk.Widget).setVisible(@intFromBool(true));
+ }
+
+ //---------------------------------------------------------------
+ // Virtual methods
+
+ fn response(
+ self: *Self,
+ response_id: [*:0]const u8,
+ ) callconv(.c) void {
+ const remember: bool = if (comptime can_remember) remember: {
+ const priv = self.private();
+ break :remember priv.remember_choice.getActive() != 0;
+ } else false;
+
+ if (std.mem.orderZ(u8, response_id, "cancel") == .eq) {
+ signals.deny.impl.emit(
+ self,
+ null,
+ .{remember},
+ null,
+ );
+ } else if (std.mem.orderZ(u8, response_id, "ok") == .eq) {
+ signals.confirm.impl.emit(
+ self,
+ null,
+ .{remember},
+ null,
+ );
+ }
+ }
+
+ fn dispose(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ if (priv.clipboard_contents) |v| {
+ v.unref();
+ priv.clipboard_contents = null;
+ }
+
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ if (priv.request) |v| {
+ glib.ext.destroy(v);
+ priv.request = null;
+ }
+
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ if (comptime adw_version.atLeast(1, 4, 0))
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 4,
+ .name = "clipboard-confirmation-dialog",
+ })
+ else
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 0,
+ .name = "clipboard-confirmation-dialog",
+ }),
+ );
+
+ // Bindings
+ class.bindTemplateChildPrivate("text_view_scroll", .{});
+ class.bindTemplateChildPrivate("text_view", .{});
+ class.bindTemplateChildPrivate("hide_button", .{});
+ class.bindTemplateChildPrivate("reveal_button", .{});
+ if (comptime can_remember) {
+ class.bindTemplateChildPrivate("remember_choice", .{});
+ }
+
+ // Template Callbacks
+ class.bindTemplateCallback("reveal_clicked", &revealButtonClicked);
+ class.bindTemplateCallback("hide_clicked", &hideButtonClicked);
+ class.bindTemplateCallback("notify_blur", &propBlur);
+ class.bindTemplateCallback("notify_request", &propRequest);
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.blur.impl,
+ properties.@"can-remember".impl,
+ properties.@"clipboard-contents".impl,
+ properties.request.impl,
+ });
+
+ // Signals
+ signals.confirm.impl.register(.{});
+ signals.deny.impl.register(.{});
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ Dialog.virtual_methods.response.implement(class, &response);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/close_confirmation_dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/close_confirmation_dialog.zig
new file mode 100644
index 00000000..5919f9c9
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/close_confirmation_dialog.zig
@@ -0,0 +1,204 @@
+const std = @import("std");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const gresource = @import("../build/gresource.zig");
+const i18n = @import("../../../os/main.zig").i18n;
+const Common = @import("../class.zig").Common;
+const Dialog = @import("dialog.zig").Dialog;
+
+const log = std.log.scoped(.gtk_ghostty_close_confirmation_dialog);
+
+pub const CloseConfirmationDialog = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = Dialog;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyCloseConfirmationDialog",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ pub const target = struct {
+ pub const name = "target";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ Target,
+ .{
+ .default = .app,
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "target",
+ ),
+ },
+ );
+ };
+ };
+
+ pub const signals = struct {
+ pub const @"close-request" = struct {
+ pub const name = "close-request";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+
+ pub const cancel = struct {
+ pub const name = "cancel";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+ };
+
+ const Private = struct {
+ target: Target,
+ pub var offset: c_int = 0;
+ };
+
+ pub fn new(target: Target) *Self {
+ return gobject.ext.newInstance(Self, .{
+ .target = target,
+ });
+ }
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+ }
+
+ pub fn present(self: *Self, parent: ?*gtk.Widget) void {
+ // Setup our title/body text.
+ const priv = self.private();
+ self.as(Dialog.Parent).setHeading(priv.target.title());
+ self.as(Dialog.Parent).setBody(priv.target.body());
+
+ // Show it
+ self.as(Dialog).present(parent);
+ }
+
+ pub fn close(self: *Self) void {
+ self.as(Dialog).close();
+ }
+
+ fn response(
+ self: *Self,
+ response_id: [*:0]const u8,
+ ) callconv(.c) void {
+ if (std.mem.orderZ(u8, response_id, "close") == .eq) {
+ signals.@"close-request".impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+ } else {
+ signals.cancel.impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+ }
+ }
+
+ fn dispose(self: *Self) callconv(.c) void {
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const refSink = C.refSink;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gobject.ext.ensureType(Dialog);
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 2,
+ .name = "close-confirmation-dialog",
+ }),
+ );
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.target.impl,
+ });
+
+ // Signals
+ signals.@"close-request".impl.register(.{});
+ signals.cancel.impl.register(.{});
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ Dialog.virtual_methods.response.implement(class, &response);
+ }
+
+ pub const as = C.Class.as;
+ };
+};
+
+/// The target of a close dialog.
+///
+/// This is here so that we can consolidate all logic related to
+/// prompting the user and closing windows/tabs/surfaces/etc.
+/// together into one struct that is the sole source of truth.
+pub const Target = enum(c_int) {
+ app,
+ tab,
+ window,
+ surface,
+
+ pub fn title(self: Target) [*:0]const u8 {
+ return switch (self) {
+ .app => i18n._("Quit Ghostty?"),
+ .tab => i18n._("Close Tab?"),
+ .window => i18n._("Close Window?"),
+ .surface => i18n._("Close Split?"),
+ };
+ }
+
+ pub fn body(self: Target) [*:0]const u8 {
+ return switch (self) {
+ .app => i18n._("All terminal sessions will be terminated."),
+ .tab => i18n._("All terminal sessions in this tab will be terminated."),
+ .window => i18n._("All terminal sessions in this window will be terminated."),
+ .surface => i18n._("The currently running process in this split will be terminated."),
+ };
+ }
+
+ pub const getGObjectType = gobject.ext.defineEnum(
+ Target,
+ .{ .name = "GhosttyCloseConfirmationDialogTarget" },
+ );
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig b/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig
new file mode 100644
index 00000000..6101e82e
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig
@@ -0,0 +1,789 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+
+const adw = @import("adw");
+const gio = @import("gio");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const input = @import("../../../input.zig");
+const gresource = @import("../build/gresource.zig");
+const key = @import("../key.zig");
+const WeakRef = @import("../weak_ref.zig").WeakRef;
+const Common = @import("../class.zig").Common;
+const Application = @import("application.zig").Application;
+const Window = @import("window.zig").Window;
+const Surface = @import("surface.zig").Surface;
+const Tab = @import("tab.zig").Tab;
+const Config = @import("config.zig").Config;
+
+const log = std.log.scoped(.gtk_ghostty_command_palette);
+
+pub const CommandPalette = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = adw.Bin;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyCommandPalette",
+ .instanceInit = &init,
+ .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(
+ name,
+ Self,
+ ?*Config,
+ .{
+ .accessor = C.privateObjFieldAccessor("config"),
+ },
+ );
+ };
+ };
+
+ pub const signals = struct {
+ /// Emitted when a command from the command palette is activated. The
+ /// action contains pointers to allocated data so if a receiver of this
+ /// signal needs to keep the action around it will need to clone the
+ /// action or there may be use-after-free errors.
+ pub const trigger = struct {
+ pub const name = "trigger";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{*const input.Binding.Action},
+ void,
+ );
+ };
+ };
+
+ const Private = struct {
+ /// The configuration that this command palette is using.
+ config: ?*Config = null,
+
+ /// The dialog object containing the palette UI.
+ dialog: *adw.Dialog,
+
+ /// The search input text field.
+ search: *gtk.SearchEntry,
+
+ /// The view containing each result row.
+ view: *gtk.ListView,
+
+ /// The model that provides filtered data for the view to display.
+ model: *gtk.SingleSelection,
+
+ /// The list that serves as the data source of the model.
+ /// This is where all command data is ultimately stored.
+ source: *gio.ListStore,
+
+ pub var offset: c_int = 0;
+ };
+
+ /// Create a new instance of the command palette. The caller will own a
+ /// reference to the object.
+ pub fn new() *Self {
+ const self = gobject.ext.newInstance(Self, .{});
+
+ // Sink ourselves so that we aren't floating anymore. We'll unref
+ // ourselves when the palette is closed or an action is activated.
+ _ = self.refSink();
+
+ // Bump the ref so that the caller has a reference.
+ return self.ref();
+ }
+
+ //---------------------------------------------------------------
+ // Virtual Methods
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+
+ // Listen for any changes to our config.
+ _ = gobject.Object.signals.notify.connect(
+ self,
+ ?*anyopaque,
+ propConfig,
+ null,
+ .{
+ .detail = "config",
+ },
+ );
+ }
+
+ fn dispose(self: *Self) callconv(.c) void {
+ const priv = self.private();
+
+ priv.source.removeAll();
+
+ if (priv.config) |config| {
+ config.unref();
+ priv.config = null;
+ }
+
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ //---------------------------------------------------------------
+ // Signal Handlers
+
+ fn propConfig(self: *CommandPalette, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void {
+ const priv = self.private();
+
+ const config = priv.config orelse {
+ log.warn("command palette does not have a config!", .{});
+ return;
+ };
+
+ // Clear existing binds
+ priv.source.removeAll();
+
+ const alloc = Application.default().allocator();
+ var commands: std.ArrayList(*Command) = .{};
+ defer {
+ for (commands.items) |cmd| cmd.unref();
+ commands.deinit(alloc);
+ }
+
+ self.collectJumpCommands(config, &commands) catch |err| {
+ log.warn("failed to collect jump commands: {}", .{err});
+ };
+
+ self.collectRegularCommands(config, &commands, alloc);
+
+ // Sort commands
+ std.mem.sort(*Command, commands.items, {}, struct {
+ fn lessThan(_: void, a: *Command, b: *Command) bool {
+ return compareCommands(a, b);
+ }
+ }.lessThan);
+
+ for (commands.items) |cmd| {
+ const cmd_ref = cmd.as(gobject.Object);
+ priv.source.append(cmd_ref);
+ }
+ }
+
+ /// Collect regular commands from configuration, filtering out unsupported actions.
+ fn collectRegularCommands(
+ self: *CommandPalette,
+ config: *Config,
+ commands: *std.ArrayList(*Command),
+ alloc: std.mem.Allocator,
+ ) void {
+ _ = self;
+ const cfg = config.get();
+
+ for (cfg.@"command-palette-entry".value.items) |command| {
+ // Filter out actions that are not implemented or don't make sense
+ // for GTK.
+ if (!isActionSupportedOnGtk(command.action)) continue;
+
+ const cmd = Command.new(config, command) catch |err| {
+ log.warn("failed to create command: {}", .{err});
+ continue;
+ };
+ errdefer cmd.unref();
+
+ commands.append(alloc, cmd) catch |err| {
+ log.warn("failed to add command to list: {}", .{err});
+ continue;
+ };
+ }
+ }
+
+ /// Check if an action is supported on GTK.
+ fn isActionSupportedOnGtk(action: input.Binding.Action) bool {
+ return switch (action) {
+ .close_all_windows,
+ .toggle_secure_input,
+ .check_for_updates,
+ .redo,
+ .undo,
+ .reset_window_size,
+ .toggle_window_float_on_top,
+ => false,
+
+ else => true,
+ };
+ }
+
+ /// Collect jump commands for all surfaces across all windows.
+ fn collectJumpCommands(
+ self: *CommandPalette,
+ config: *Config,
+ commands: *std.ArrayList(*Command),
+ ) !void {
+ _ = self;
+ const app = Application.default();
+ const alloc = app.allocator();
+
+ // Get all surfaces from the core app
+ const core_app = app.core();
+ for (core_app.surfaces.items) |apprt_surface| {
+ const surface = apprt_surface.gobj();
+ const cmd = Command.newJump(config, surface);
+ errdefer cmd.unref();
+ try commands.append(alloc, cmd);
+ }
+ }
+
+ /// Compare two commands for sorting.
+ /// Sorts alphabetically by title (case-insensitive), with colon normalization
+ /// so "Foo:" sorts before "Foo Bar:". Uses sort_key as tie-breaker.
+ fn compareCommands(a: *Command, b: *Command) bool {
+ const a_title = a.propGetTitle() orelse return false;
+ const b_title = b.propGetTitle() orelse return true;
+
+ // Compare case-insensitively with colon normalization
+ for (0..@min(a_title.len, b_title.len)) |i| {
+ // Get characters, replacing ':' with '\t'
+ const a_char = if (a_title[i] == ':') '\t' else a_title[i];
+ const b_char = if (b_title[i] == ':') '\t' else b_title[i];
+
+ const a_lower = std.ascii.toLower(a_char);
+ const b_lower = std.ascii.toLower(b_char);
+
+ if (a_lower != b_lower) {
+ return a_lower < b_lower;
+ }
+ }
+
+ // If one title is a prefix of the other, shorter one comes first
+ if (a_title.len != b_title.len) {
+ return a_title.len < b_title.len;
+ }
+
+ // Titles are equal - use sort_key as tie-breaker if both are jump commands
+ const a_sort_key = switch (a.private().data) {
+ .regular => return false,
+ .jump => |*ja| ja.sort_key,
+ };
+ const b_sort_key = switch (b.private().data) {
+ .regular => return false,
+ .jump => |*jb| jb.sort_key,
+ };
+
+ return a_sort_key < b_sort_key;
+ }
+
+ fn close(self: *CommandPalette) void {
+ const priv = self.private();
+ _ = priv.dialog.close();
+ }
+
+ fn dialogClosed(_: *adw.Dialog, self: *CommandPalette) callconv(.c) void {
+ self.unref();
+ }
+
+ fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void {
+ // ESC was pressed - close the palette
+ self.close();
+ }
+
+ fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void {
+ // If Enter is pressed, activate the selected entry
+ const priv = self.private();
+ self.activated(priv.model.getSelected());
+ }
+
+ fn rowActivated(_: *gtk.ListView, pos: c_uint, self: *CommandPalette) callconv(.c) void {
+ self.activated(pos);
+ }
+
+ //---------------------------------------------------------------
+
+ /// Show or hide the command palette dialog. If the dialog is shown it will
+ /// be modal over the given window.
+ pub fn toggle(self: *CommandPalette, window: *Window) void {
+ const priv = self.private();
+
+ // If the dialog has been shown, close it.
+ if (priv.dialog.as(gtk.Widget).getRealized() != 0) {
+ self.close();
+ return;
+ }
+
+ // Show the dialog
+ priv.dialog.present(window.as(gtk.Widget));
+
+ // Focus on the search bar when opening the dialog
+ _ = priv.search.as(gtk.Widget).grabFocus();
+ }
+
+ /// Helper function to send a signal containing the action that should be
+ /// performed.
+ fn activated(self: *CommandPalette, pos: c_uint) void {
+ const priv = self.private();
+
+ // Use priv.model and not priv.source here to use the list of *visible* results
+ const object_ = priv.model.as(gio.ListModel).getObject(pos);
+ defer if (object_) |object| object.unref();
+
+ // Close before running the action in order to avoid being replaced by
+ // another dialog (such as the change title dialog). If that occurs then
+ // the command palette dialog won't be counted as having closed properly
+ // and cannot receive focus when reopened.
+ self.close();
+
+ const cmd = gobject.ext.cast(Command, object_ orelse return) orelse return;
+
+ // Handle jump commands differently
+ if (cmd.isJump()) {
+ const surface = cmd.getJumpSurface() orelse return;
+ defer surface.unref();
+ surface.present();
+ return;
+ }
+
+ // Regular command - emit trigger signal
+ const action = cmd.getAction() orelse return;
+
+ // Signal that an action has been selected. Signals are synchronous
+ // so we shouldn't need to worry about cloning the action.
+ signals.trigger.impl.emit(
+ self,
+ null,
+ .{&action},
+ null,
+ );
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const refSink = C.refSink;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gobject.ext.ensureType(Command);
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 5,
+ .name = "command-palette",
+ }),
+ );
+
+ // Bindings
+ class.bindTemplateChildPrivate("dialog", .{});
+ class.bindTemplateChildPrivate("search", .{});
+ class.bindTemplateChildPrivate("view", .{});
+ class.bindTemplateChildPrivate("model", .{});
+ class.bindTemplateChildPrivate("source", .{});
+
+ // Template Callbacks
+ class.bindTemplateCallback("closed", &dialogClosed);
+ class.bindTemplateCallback("notify_config", &propConfig);
+ class.bindTemplateCallback("search_stopped", &searchStopped);
+ class.bindTemplateCallback("search_activated", &searchActivated);
+ class.bindTemplateCallback("row_activated", &rowActivated);
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.config.impl,
+ });
+
+ // Signals
+ signals.trigger.impl.register(.{});
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
+
+/// Object that wraps around a command.
+///
+/// As GTK list models only accept objects that are within the GObject hierarchy,
+/// we have to construct a wrapper to be easily consumed by the list model.
+const Command = extern struct {
+ pub const Self = @This();
+ pub const Parent = gobject.Object;
+ parent: Parent,
+
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyCommand",
+ .instanceInit = &init,
+ .classInit = Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ const properties = struct {
+ pub const config = struct {
+ pub const name = "config";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Config,
+ .{
+ .accessor = C.privateObjFieldAccessor("config"),
+ },
+ );
+ };
+
+ pub const action_key = struct {
+ pub const name = "action-key";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?[:0]const u8,
+ .{
+ .getter = propGetActionKey,
+ .getter_transfer = .none,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const action = struct {
+ pub const name = "action";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?[:0]const u8,
+ .{
+ .getter = propGetAction,
+ .getter_transfer = .none,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const title = struct {
+ pub const name = "title";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?[:0]const u8,
+ .{
+ .getter = propGetTitle,
+ .getter_transfer = .none,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const description = struct {
+ pub const name = "description";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?[:0]const u8,
+ .{
+ .getter = propGetDescription,
+ .getter_transfer = .none,
+ },
+ ),
+ },
+ );
+ };
+ };
+
+ pub const Private = struct {
+ config: ?*Config = null,
+ arena: ArenaAllocator,
+ data: CommandData,
+
+ pub var offset: c_int = 0;
+
+ pub const CommandData = union(enum) {
+ regular: RegularData,
+ jump: JumpData,
+ };
+
+ pub const RegularData = struct {
+ command: input.Command,
+ action: ?[:0]const u8 = null,
+ action_key: ?[:0]const u8 = null,
+ };
+
+ pub const JumpData = struct {
+ surface: WeakRef(Surface) = .empty,
+ title: ?[:0]const u8 = null,
+ description: ?[:0]const u8 = null,
+ sort_key: usize,
+ };
+ };
+
+ pub fn new(config: *Config, command: input.Command) Allocator.Error!*Self {
+ const self = gobject.ext.newInstance(Self, .{
+ .config = config,
+ });
+ errdefer self.unref();
+
+ const priv = self.private();
+ const cloned = try command.clone(priv.arena.allocator());
+
+ priv.data = .{
+ .regular = .{
+ .command = cloned,
+ },
+ };
+
+ return self;
+ }
+
+ /// Create a new jump command that focuses a specific surface.
+ pub fn newJump(config: *Config, surface: *Surface) *Self {
+ const self = gobject.ext.newInstance(Self, .{
+ .config = config,
+ });
+
+ const priv = self.private();
+ priv.data = .{
+ .jump = .{
+ // TODO: Replace with surface id whenever Ghostty adds one
+ .sort_key = @intFromPtr(surface),
+ },
+ };
+ priv.data.jump.surface.set(surface);
+
+ return self;
+ }
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ // NOTE: we do not watch for changes to the config here as the command
+ // palette will destroy and recreate this object if/when the config
+ // changes.
+
+ const priv = self.private();
+ priv.arena = .init(Application.default().allocator());
+ }
+
+ fn dispose(self: *Self) callconv(.c) void {
+ const priv = self.private();
+
+ if (priv.config) |config| {
+ config.unref();
+ priv.config = null;
+ }
+
+ switch (priv.data) {
+ .regular => {},
+ .jump => |*j| {
+ j.surface.set(null);
+ },
+ }
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ const priv = self.private();
+
+ priv.arena.deinit();
+
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ //---------------------------------------------------------------
+
+ fn propGetActionKey(self: *Self) ?[:0]const u8 {
+ const priv = self.private();
+
+ const regular = switch (priv.data) {
+ .regular => |*r| r,
+ .jump => return null,
+ };
+
+ if (regular.action_key) |action_key| return action_key;
+
+ regular.action_key = std.fmt.allocPrintSentinel(
+ priv.arena.allocator(),
+ "{f}",
+ .{regular.command.action},
+ 0,
+ ) catch null;
+
+ return regular.action_key;
+ }
+
+ fn propGetAction(self: *Self) ?[:0]const u8 {
+ const priv = self.private();
+
+ const regular = switch (priv.data) {
+ .regular => |*r| r,
+ .jump => return null,
+ };
+
+ if (regular.action) |action| return action;
+
+ const cfg = if (priv.config) |config| config.get() else return null;
+ const keybinds = cfg.keybind.set;
+
+ const alloc = priv.arena.allocator();
+
+ regular.action = action: {
+ var buf: [64]u8 = undefined;
+ const trigger = keybinds.getTrigger(regular.command.action) orelse break :action null;
+ const accel = (key.accelFromTrigger(&buf, trigger) catch break :action null) orelse break :action null;
+ break :action alloc.dupeZ(u8, accel) catch return null;
+ };
+
+ return regular.action;
+ }
+
+ fn propGetTitle(self: *Self) ?[:0]const u8 {
+ const priv = self.private();
+
+ switch (priv.data) {
+ .regular => |*r| return r.command.title,
+ .jump => |*j| {
+ if (j.title) |title| return title;
+
+ const surface = j.surface.get() orelse return null;
+ defer surface.unref();
+
+ const alloc = priv.arena.allocator();
+ const effective_title = surface.getEffectiveTitle() orelse "Untitled";
+
+ j.title = std.fmt.allocPrintSentinel(
+ alloc,
+ "Focus: {s}",
+ .{effective_title},
+ 0,
+ ) catch null;
+
+ return j.title;
+ },
+ }
+ }
+
+ fn propGetDescription(self: *Self) ?[:0]const u8 {
+ const priv = self.private();
+
+ switch (priv.data) {
+ .regular => |*r| return r.command.description,
+ .jump => |*j| {
+ if (j.description) |desc| return desc;
+
+ const surface = j.surface.get() orelse return null;
+ defer surface.unref();
+
+ const alloc = priv.arena.allocator();
+ const title = surface.getEffectiveTitle() orelse "Untitled";
+ const pwd = surface.getPwd();
+
+ if (pwd) |p| {
+ if (std.mem.indexOf(u8, title, p) == null) {
+ j.description = alloc.dupeZ(u8, p) catch null;
+ }
+ }
+
+ return j.description;
+ },
+ }
+ }
+
+ //---------------------------------------------------------------
+
+ /// Return a copy of the action. Callers must ensure that they do not use
+ /// the action beyond the lifetime of this object because it has internally
+ /// allocated data that will be freed when this object is.
+ pub fn getAction(self: *Self) ?input.Binding.Action {
+ const priv = self.private();
+ return switch (priv.data) {
+ .regular => |*r| r.command.action,
+ .jump => null,
+ };
+ }
+
+ /// Check if this is a jump command.
+ pub fn isJump(self: *Self) bool {
+ const priv = self.private();
+ return priv.data == .jump;
+ }
+
+ /// Get the jump surface. Returns a strong reference that the caller
+ /// must unref when done, or null if the surface has been destroyed.
+ pub fn getJumpSurface(self: *Self) ?*Surface {
+ const priv = self.private();
+ return switch (priv.data) {
+ .regular => null,
+ .jump => |*j| j.surface.get(),
+ };
+ }
+
+ //---------------------------------------------------------------
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gobject.ext.registerProperties(class, &.{
+ properties.config.impl,
+ properties.action_key.impl,
+ properties.action.impl,
+ properties.title.impl,
+ properties.description.impl,
+ });
+
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ }
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/config.zig b/vendor/libghostty-vt/src/apprt/gtk/class/config.zig
new file mode 100644
index 00000000..9a705d35
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/config.zig
@@ -0,0 +1,175 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const configpkg = @import("../../../config.zig");
+const CoreConfig = configpkg.Config;
+
+const Common = @import("../class.zig").Common;
+
+const log = std.log.scoped(.gtk_ghostty_config);
+
+/// Wraps a `Ghostty.Config` object in a GObject so it can be reference
+/// counted. When this object is freed, the underlying config is also freed.
+///
+/// It is highly recommended to NOT take a reference to this object,
+/// since configuration takes up a lot of memory (relatively). Instead,
+/// receivers of this should usually create a `DerivedConfig` struct from
+/// this, copy any memory they require, and own that structure instead.
+///
+/// This can also expose helpers to access configuration in ways that
+/// may be more ergonomic to GTK primitives.
+pub const Config = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = gobject.Object;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyConfig",
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ pub const @"diagnostics-buffer" = gobject.ext.defineProperty(
+ "diagnostics-buffer",
+ Self,
+ ?*gtk.TextBuffer,
+ .{
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?*gtk.TextBuffer,
+ .{
+ .getter = Self.diagnosticsBuffer,
+ .getter_transfer = .full,
+ },
+ ),
+ },
+ );
+
+ pub const @"has-diagnostics" = gobject.ext.defineProperty(
+ "has-diagnostics",
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ bool,
+ .{
+ .getter = Self.hasDiagnostics,
+ },
+ ),
+ },
+ );
+ };
+
+ const Private = struct {
+ config: CoreConfig,
+
+ pub var offset: c_int = 0;
+ };
+
+ /// Create a new GhosttyConfig from a loaded configuration.
+ ///
+ /// This clones the given configuration, so it is safe for the
+ /// caller to free the original configuration after this call.
+ pub fn new(alloc: Allocator, config: *const CoreConfig) Allocator.Error!*Self {
+ const self = gobject.ext.newInstance(Self, .{});
+ errdefer self.unref();
+
+ const priv = self.private();
+ priv.config = try config.clone(alloc);
+
+ return self;
+ }
+
+ /// Get the wrapped configuration. It's unsafe to store this or access
+ /// it in any way that may live beyond the lifetime of this object.
+ pub fn get(self: *Self) *const CoreConfig {
+ return &self.private().config;
+ }
+
+ /// Get the mutable configuration. This is usually NOT recommended
+ /// because any changes to the config won't be propagated to anyone
+ /// with a reference to this object. If you know what you're doing, then
+ /// you can use this.
+ pub fn getMut(self: *Self) *CoreConfig {
+ return &self.private().config;
+ }
+
+ /// Returns whether this configuration has any diagnostics.
+ pub fn hasDiagnostics(self: *Self) bool {
+ const config = self.get();
+ return !config._diagnostics.empty();
+ }
+
+ /// Reads the diagnostics of this configuration as a TextBuffer,
+ /// or returns null if there are no diagnostics.
+ pub fn diagnosticsBuffer(self: *Self) ?*gtk.TextBuffer {
+ const config = self.get();
+ if (config._diagnostics.empty()) return null;
+
+ const text_buf: *gtk.TextBuffer = .new(null);
+ errdefer text_buf.unref();
+
+ var buf: [4095:0]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ for (config._diagnostics.items()) |diag| {
+ writer.end = 0;
+ diag.format(&writer) catch |err| {
+ log.warn(
+ "error writing diagnostic to buffer err={}",
+ .{err},
+ );
+ continue;
+ };
+
+ text_buf.insertAtCursor(&buf, @intCast(writer.end));
+ text_buf.insertAtCursor("\n", 1);
+ }
+
+ return text_buf;
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ self.private().config.deinit();
+
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ gobject.ext.registerProperties(class, &.{
+ properties.@"diagnostics-buffer",
+ properties.@"has-diagnostics",
+ });
+ }
+ };
+};
+
+// This test verifies our memory management works as expected. Since
+// we use the testing allocator any leaks are detected.
+test "GhosttyConfig" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var config: CoreConfig = try .default(alloc);
+ defer config.deinit();
+ const obj: *Config = try .new(alloc, &config);
+ obj.unref();
+}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/config_errors_dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/config_errors_dialog.zig
new file mode 100644
index 00000000..46d5fe62
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/config_errors_dialog.zig
@@ -0,0 +1,161 @@
+const std = @import("std");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const gresource = @import("../build/gresource.zig");
+const Common = @import("../class.zig").Common;
+const Config = @import("config.zig").Config;
+const Dialog = @import("dialog.zig").Dialog;
+
+const log = std.log.scoped(.gtk_ghostty_config_errors_dialog);
+
+pub const ConfigErrorsDialog = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = Dialog;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyConfigErrorsDialog",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ pub const config = gobject.ext.defineProperty(
+ "config",
+ Self,
+ ?*Config,
+ .{
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?*Config,
+ .{
+ .getter = Self.getConfig,
+ .setter = Self.setConfig,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const signals = struct {
+ pub const @"reload-config" = struct {
+ pub const name = "reload-config";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+ };
+
+ const Private = struct {
+ config: ?*Config,
+ pub var offset: c_int = 0;
+ };
+
+ pub fn new(config: *Config) *Self {
+ return gobject.ext.newInstance(Self, .{
+ .config = config,
+ });
+ }
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+ }
+
+ pub fn present(self: *Self, parent: ?*gtk.Widget) void {
+ self.as(Dialog).present(parent);
+ }
+
+ pub fn close(self: *Self) void {
+ self.as(Dialog).close();
+ }
+
+ fn response(
+ self: *Self,
+ response_id: [*:0]const u8,
+ ) callconv(.c) void {
+ if (std.mem.orderZ(u8, response_id, "reload") != .eq) return;
+ signals.@"reload-config".impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+ }
+
+ fn dispose(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ if (priv.config) |v| {
+ v.unref();
+ priv.config = null;
+ }
+
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn getConfig(self: *Self) ?*Config {
+ return self.private().config;
+ }
+
+ fn setConfig(self: *Self, config: ?*Config) void {
+ const priv = self.private();
+ if (priv.config) |old| old.unref();
+
+ // We don't need to increase the reference count because
+ // the property setter handles it (uses GValue.get vs. take)
+ priv.config = config;
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gobject.ext.ensureType(Dialog);
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 2,
+ .name = "config-errors-dialog",
+ }),
+ );
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.config,
+ });
+
+ // Signals
+ signals.@"reload-config".impl.register(.{});
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ Dialog.virtual_methods.response.implement(class, &response);
+ }
+
+ pub fn as(class: *Class, comptime T: type) *T {
+ return gobject.ext.as(T, class);
+ }
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/debug_warning.zig b/vendor/libghostty-vt/src/apprt/gtk/class/debug_warning.zig
new file mode 100644
index 00000000..0ad32033
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/debug_warning.zig
@@ -0,0 +1,50 @@
+const adw = @import("adw");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const adw_version = @import("../adw_version.zig");
+const gresource = @import("../build/gresource.zig");
+const Common = @import("../class.zig").Common;
+
+/// Debug warning banner. It will be based on adw.Banner if we're using Adwaita
+/// 1.3 or newer. Otherwise it will use a gtk.Label.
+pub const DebugWarning = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = if (adw_version.supportsBanner()) adw.Bin else gtk.Box;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyDebugWarning",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ });
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+ }
+
+ const C = Common(Self, null);
+ 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 {
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = if (adw_version.supportsBanner()) 3 else 2,
+ .name = "debug-warning",
+ }),
+ );
+ }
+
+ pub const as = C.Class.as;
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/dialog.zig
new file mode 100644
index 00000000..5bc3cdfa
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/dialog.zig
@@ -0,0 +1,91 @@
+const std = @import("std");
+const adw = @import("adw");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const adw_version = @import("../adw_version.zig");
+const Common = @import("../class.zig").Common;
+
+const log = std.log.scoped(.gtk_ghostty_dialog);
+
+/// Dialog is a simple abstraction over the `adw.AlertDialog` and
+/// `adw.MessageDialog` widgets, chosen at comptime based on the linked
+/// Adwaita version.
+///
+/// Once we drop support for Adwaita <= 1.2, this can be fully removed
+/// and we can use `adw.AlertDialog` directly.
+pub const Dialog = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+
+ pub const Parent = if (adw_version.supportsDialogs())
+ adw.AlertDialog
+ else
+ adw.MessageDialog;
+
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyDialog",
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ });
+
+ pub const virtual_methods = struct {
+ /// Forwarded from parent so subclasses can reference this
+ /// directly. This will make it easier to remove Dialog in the future.
+ pub const response = Parent.virtual_methods.response;
+ };
+
+ pub fn present(self: *Self, parent: ?*gtk.Widget) void {
+ switch (Parent) {
+ adw.AlertDialog => self.as(adw.Dialog).present(parent),
+
+ adw.MessageDialog => {
+ // Reset the previous parent window
+ self.as(gtk.Window).setTransientFor(null);
+
+ // We need to get the window for the parent in order
+ // to set the transient-for property on the MessageDialog.
+ if (parent) |widget| parent: {
+ const root = gtk.Widget.getRoot(widget) orelse break :parent;
+ const window = gobject.ext.cast(
+ gtk.Window,
+ root,
+ ) orelse break :parent;
+ self.as(gtk.Window).setTransientFor(window);
+ }
+
+ self.as(gtk.Window).present();
+ },
+
+ else => comptime unreachable,
+ }
+ }
+
+ pub fn close(self: *Self) void {
+ switch (Parent) {
+ adw.AlertDialog => self.as(adw.Dialog).forceClose(),
+ adw.MessageDialog => self.as(gtk.Window).close(),
+ else => comptime unreachable,
+ }
+ }
+
+ const C = Common(Self, null);
+ 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 {
+ _ = class;
+ }
+
+ pub fn as(class: *Class, comptime T: type) *T {
+ return gobject.ext.as(T, class);
+ }
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig b/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig
new file mode 100644
index 00000000..cf0f31a6
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig
@@ -0,0 +1,633 @@
+const std = @import("std");
+const assert = @import("../../../quirks.zig").inlineAssert;
+const Allocator = std.mem.Allocator;
+const gio = @import("gio");
+const glib = @import("glib");
+const gobject = @import("gobject");
+
+const Binding = @import("../../../input.zig").Binding;
+const key = @import("../key.zig");
+const Common = @import("../class.zig").Common;
+const Application = @import("application.zig").Application;
+const Config = @import("config.zig").Config;
+
+const log = std.log.scoped(.gtk_ghostty_global_shortcuts);
+
+pub const GlobalShortcuts = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = gobject.Object;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyGlobalShortcuts",
+ .instanceInit = &init,
+ .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(
+ name,
+ Self,
+ ?*Config,
+ .{
+ .accessor = C.privateObjFieldAccessor("config"),
+ },
+ );
+ };
+
+ pub const @"dbus-connection" = struct {
+ pub const name = "dbus-connection";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*gio.DBusConnection,
+ .{
+ .accessor = C.privateObjFieldAccessor("dbus_connection"),
+ },
+ );
+ };
+ };
+
+ const Private = struct {
+ /// The configuration that this is using.
+ config: ?*Config = null,
+
+ /// The dbus connection.
+ dbus_connection: ?*gio.DBusConnection = null,
+
+ /// An arena allocator that is present for each refresh.
+ arena: ?std.heap.ArenaAllocator = null,
+
+ /// A mapping from a unique ID to an action.
+ /// Currently the unique ID is simply the serialized representation of the
+ /// trigger that was used for the action as triggers are unique in the keymap,
+ /// but this may change in the future.
+ map: std.StringArrayHashMapUnmanaged(Binding.Action) = .{},
+
+ /// The handle of the current global shortcuts portal session,
+ /// as a D-Bus object path.
+ handle: ?[:0]const u8 = null,
+
+ /// The D-Bus signal subscription for the response signal on requests.
+ /// The ID is guaranteed to be non-zero, so we can use 0 to indicate null.
+ response_subscription: c_uint = 0,
+
+ /// The D-Bus signal subscription for the keybind activate signal.
+ /// The ID is guaranteed to be non-zero, so we can use 0 to indicate null.
+ activate_subscription: c_uint = 0,
+
+ pub var offset: c_int = 0;
+ };
+
+ pub const signals = struct {
+ /// Emitted whenever a global shortcut is triggered.
+ pub const trigger = struct {
+ pub const name = "trigger";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{*const Binding.Action},
+ void,
+ );
+ };
+ };
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ _ = gobject.Object.signals.notify.connect(
+ self,
+ *Self,
+ propConfig,
+ self,
+ .{ .detail = "config" },
+ );
+ }
+
+ fn close(self: *Self) void {
+ const priv = self.private();
+ const dbus = priv.dbus_connection orelse return;
+
+ if (priv.response_subscription != 0) {
+ dbus.signalUnsubscribe(priv.response_subscription);
+ priv.response_subscription = 0;
+ }
+
+ if (priv.activate_subscription != 0) {
+ dbus.signalUnsubscribe(priv.activate_subscription);
+ priv.activate_subscription = 0;
+ }
+
+ if (priv.handle) |handle| {
+ // Close existing session
+ dbus.call(
+ "org.freedesktop.portal.Desktop",
+ handle,
+ "org.freedesktop.portal.Session",
+ "Close",
+ null,
+ null,
+ .{},
+ -1,
+ null,
+ null,
+ null,
+ );
+ priv.handle = null;
+ }
+
+ if (priv.arena) |*arena| {
+ arena.deinit();
+ priv.arena = null;
+ priv.map = .{}; // Uses arena memory
+ }
+ }
+
+ fn refresh(self: *Self) Allocator.Error!void {
+ // Always close our previous state first.
+ self.close();
+
+ const priv = self.private();
+
+ // We need configuration to proceed.
+ const config = if (priv.config) |v| v.get() else return;
+
+ // Setup our new arena that we'll use for memory allocations.
+ assert(priv.arena == null);
+ var arena: std.heap.ArenaAllocator = .init(Application.default().allocator());
+ errdefer arena.deinit();
+ const alloc = arena.allocator();
+
+ // Our map starts out empty again. We don't need to worry about
+ // memory because its part of the arena we clear.
+ priv.map = .{};
+ errdefer priv.map = .{};
+
+ // Update map
+ var trigger_buf: [1024]u8 = undefined;
+ var it = config.keybind.set.bindings.iterator();
+ while (it.next()) |entry| {
+ const leaf: Binding.Set.GenericLeaf = switch (entry.value_ptr.*) {
+ .leader => continue,
+ inline .leaf, .leaf_chained => |leaf| leaf.generic(),
+ };
+ if (!leaf.flags.global) continue;
+
+ // We only allow global keybinds that map to exactly a single
+ // action for now. TODO: remove this restriction
+ const actions = leaf.actionsSlice();
+ if (actions.len != 1) continue;
+
+ const trigger = if (key.xdgShortcutFromTrigger(
+ &trigger_buf,
+ entry.key_ptr.*,
+ )) |shortcut_|
+ shortcut_ orelse continue
+ else |err| switch (err) {
+ // If there isn't space to translate the trigger, then our
+ // buffer might be too small (but 1024 is insane!). In any case
+ // we don't want to stop registering globals.
+ error.WriteFailed => {
+ log.warn(
+ "buffer too small to translate trigger, ignoring={f}",
+ .{entry.key_ptr.*},
+ );
+ continue;
+ },
+ };
+
+ try priv.map.put(
+ alloc,
+ try alloc.dupeZ(u8, trigger),
+ actions[0],
+ );
+ }
+
+ // Store our arena
+ priv.arena = arena;
+
+ // Create our session if we have global shortcuts.
+ if (priv.map.count() > 0) try self.request(.create_session);
+ }
+
+ const Method = enum {
+ create_session,
+ bind_shortcuts,
+
+ fn name(self: Method) [:0]const u8 {
+ return switch (self) {
+ .create_session => "CreateSession",
+ .bind_shortcuts => "BindShortcuts",
+ };
+ }
+
+ /// Construct the payload expected by the XDG portal call.
+ fn makePayload(
+ self: Method,
+ shortcuts: *GlobalShortcuts,
+ request_token: [:0]const u8,
+ ) ?*glib.Variant {
+ switch (self) {
+ // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession
+ .create_session => {
+ var session_token: Token = undefined;
+ return glib.Variant.newParsed(
+ "({'handle_token': <%s>, 'session_handle_token': <%s>},)",
+ request_token.ptr,
+ generateToken(&session_token).ptr,
+ );
+ },
+
+ // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts
+ .bind_shortcuts => {
+ const priv = shortcuts.private();
+ const handle = priv.handle orelse return null;
+
+ const bind_type = glib.VariantType.new("a(sa{sv})");
+ defer glib.free(bind_type);
+
+ var binds: glib.VariantBuilder = undefined;
+ glib.VariantBuilder.init(&binds, bind_type);
+
+ var action_buf: [256]u8 = undefined;
+
+ var it = priv.map.iterator();
+ while (it.next()) |entry| {
+ const trigger = entry.key_ptr.*.ptr;
+ const action = std.fmt.bufPrintZ(
+ &action_buf,
+ "{f}",
+ .{entry.value_ptr.*},
+ ) catch continue;
+
+ binds.addParsed(
+ "(%s, {'description': <%s>, 'preferred_trigger': <%s>})",
+ trigger,
+ action.ptr,
+ trigger,
+ );
+ }
+
+ return glib.Variant.newParsed(
+ "(%o, %*, '', {'handle_token': <%s>})",
+ handle.ptr,
+ binds.end(),
+ request_token.ptr,
+ );
+ },
+ }
+ }
+
+ fn onResponse(self: Method, shortcuts: *GlobalShortcuts, vardict: *glib.Variant) void {
+ switch (self) {
+ .create_session => {
+ var handle: ?[*:0]u8 = null;
+ if (vardict.lookup("session_handle", "&s", &handle) == 0) {
+ log.warn(
+ "session handle not found in response={s}",
+ .{vardict.print(@intFromBool(true))},
+ );
+ return;
+ }
+
+ const priv = shortcuts.private();
+ const dbus = priv.dbus_connection.?;
+ const alloc = priv.arena.?.allocator();
+ priv.handle = alloc.dupeZ(u8, std.mem.span(handle.?)) catch {
+ log.warn("out of memory: failed to clone session handle", .{});
+ return;
+ };
+ log.debug("session_handle={?s}", .{handle});
+
+ // Subscribe to keybind activations
+ assert(priv.activate_subscription == 0);
+ priv.activate_subscription = dbus.signalSubscribe(
+ null,
+ "org.freedesktop.portal.GlobalShortcuts",
+ "Activated",
+ "/org/freedesktop/portal/desktop",
+ handle,
+ .{ .match_arg0_path = true },
+ shortcutActivated,
+ shortcuts,
+ null,
+ );
+
+ shortcuts.request(.bind_shortcuts) catch |err| {
+ log.warn("failed to bind shortcuts={}", .{err});
+ return;
+ };
+ },
+ .bind_shortcuts => {},
+ }
+ }
+ };
+
+ /// Submit a request to the global shortcuts portal.
+ fn request(
+ self: *Self,
+ comptime method: Method,
+ ) Allocator.Error!void {
+ // NOTE(pluiedev):
+ // XDG Portals are really, really poorly-designed pieces of hot garbage.
+ // How the protocol is _initially_ designed to work is as follows:
+ //
+ // 1. The client calls a method which returns the path of a Request object;
+ // 2. The client waits for the Response signal under said object path;
+ // 3. When the signal arrives, the actual return value and status code
+ // become available for the client for further processing.
+ //
+ // THIS DOES NOT WORK. Once the first two steps are complete, the client
+ // needs to immediately start listening for the third step, but an overeager
+ // server implementation could easily send the Response signal before the
+ // client is even ready, causing communications to break down over a simple
+ // race condition/two generals' problem that even _TCP_ had figured out
+ // decades ago. Worse yet, you get exactly _one_ chance to listen for the
+ // signal, or else your communication attempt so far has all been in vain.
+ //
+ // And they know this. Instead of fixing their freaking protocol, they just
+ // ask clients to manually construct the expected object path and subscribe
+ // to the request signal beforehand, making the whole response value of
+ // the original call COMPLETELY MEANINGLESS.
+ //
+ // Furthermore, this is _entirely undocumented_ aside from one tiny
+ // paragraph under the documentation for the Request interface, and
+ // anyone would be forgiven for missing it without reading the libportal
+ // source code.
+ //
+ // When in Rome, do as the Romans do, I guess...?
+
+ const callbacks = struct {
+ fn gotResponseHandle(
+ source: ?*gobject.Object,
+ res: *gio.AsyncResult,
+ _: ?*anyopaque,
+ ) callconv(.c) void {
+ const dbus_ = gobject.ext.cast(gio.DBusConnection, source.?).?;
+
+ var err: ?*glib.Error = null;
+ defer if (err) |err_| err_.free();
+
+ const params_ = dbus_.callFinish(res, &err) orelse {
+ if (err) |err_| log.warn("request failed={s} ({})", .{
+ err_.f_message orelse "(unknown)",
+ err_.f_code,
+ });
+ return;
+ };
+ defer params_.unref();
+
+ // TODO: XDG recommends updating the signal subscription if the actual
+ // returned request path is not the same as the expected request
+ // path, to retain compatibility with older versions of XDG portals.
+ // Although it suffers from the race condition outlined above,
+ // we should still implement this at some point.
+ }
+
+ // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html#org-freedesktop-portal-request-response
+ fn responded(
+ dbus: *gio.DBusConnection,
+ _: ?[*:0]const u8,
+ _: [*:0]const u8,
+ _: [*:0]const u8,
+ _: [*:0]const u8,
+ params_: *glib.Variant,
+ ud: ?*anyopaque,
+ ) callconv(.c) void {
+ const self_cb: *GlobalShortcuts = @ptrCast(@alignCast(ud));
+ const priv = self_cb.private();
+
+ // Unsubscribe from the response signal
+ if (priv.response_subscription != 0) {
+ dbus.signalUnsubscribe(priv.response_subscription);
+ priv.response_subscription = 0;
+ }
+
+ var response: u32 = 0;
+ var vardict: ?*glib.Variant = null;
+ defer if (vardict) |v| v.unref();
+ params_.get("(u@a{sv})", &response, &vardict);
+
+ switch (response) {
+ 0 => {
+ log.debug("request successful", .{});
+ method.onResponse(self_cb, vardict.?);
+ },
+ 1 => log.debug("request was cancelled by user", .{}),
+ 2 => log.warn("request ended unexpectedly", .{}),
+ else => log.warn("unrecognized response code={}", .{response}),
+ }
+ }
+ };
+
+ var request_token_buf: Token = undefined;
+ const request_token = generateToken(&request_token_buf);
+
+ const payload = method.makePayload(self, request_token) orelse return;
+ const request_path = try self.getRequestPath(request_token);
+
+ const priv = self.private();
+ const dbus = priv.dbus_connection.?;
+
+ assert(priv.response_subscription == 0);
+ priv.response_subscription = dbus.signalSubscribe(
+ null,
+ "org.freedesktop.portal.Request",
+ "Response",
+ request_path,
+ null,
+ .{},
+ callbacks.responded,
+ self,
+ null,
+ );
+
+ dbus.call(
+ "org.freedesktop.portal.Desktop",
+ "/org/freedesktop/portal/desktop",
+ "org.freedesktop.portal.GlobalShortcuts",
+ method.name(),
+ payload,
+ null,
+ .{},
+ -1,
+ null,
+ callbacks.gotResponseHandle,
+ null,
+ );
+ }
+
+ /// Get the XDG portal request path for the current Ghostty instance.
+ ///
+ /// If this sounds like nonsense, see `request` for an explanation as to
+ /// why we need to do this.
+ ///
+ /// Precondition: dbus connection exists, arena setup
+ fn getRequestPath(self: *Self, token: [:0]const u8) Allocator.Error![:0]const u8 {
+ const priv = self.private();
+ const dbus = priv.dbus_connection.?;
+ const alloc = priv.arena.?.allocator();
+
+ // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html
+ // for the syntax XDG portals expect.
+
+ // `getUniqueName` should never return null here as we're using an ordinary
+ // message bus connection. If it doesn't, something is very wrong
+ const unique_name = std.mem.span(dbus.getUniqueName().?);
+
+ const object_path = try std.mem.joinZ(
+ alloc,
+ "/",
+ &.{
+ "/org/freedesktop/portal/desktop/request",
+ unique_name[1..], // Remove leading `:`
+ token,
+ },
+ );
+
+ // Sanitize the unique name by replacing every `.` with `_`.
+ // In effect, this will turn a unique name like `:1.192` into `1_192`.
+ // Valid D-Bus object path components never contain `.`s anyway, so we're
+ // free to replace all instances of `.` here and avoid extra allocation.
+ std.mem.replaceScalar(u8, object_path, '.', '_');
+
+ return object_path;
+ }
+
+ //---------------------------------------------------------------
+ // Property Handlers
+
+ pub fn setDbusConnection(
+ self: *Self,
+ dbus_connection: ?*gio.DBusConnection,
+ ) void {
+ const priv = self.private();
+
+ // If we have a prior dbus connection we need to close our prior
+ // registrations first.
+ if (priv.dbus_connection) |v| {
+ self.close();
+ v.unref();
+ priv.dbus_connection = null;
+ }
+
+ priv.dbus_connection = null;
+ if (dbus_connection) |v| {
+ v.ref(); // Weird this doesn't return self
+ priv.dbus_connection = v;
+ self.refresh() catch |err| {
+ log.warn("error refreshing global shortcuts: {}", .{err});
+ };
+ }
+
+ self.as(gobject.Object).notifyByPspec(properties.@"dbus-connection".impl.param_spec);
+ }
+
+ fn propConfig(
+ _: *Self,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ self.refresh() catch |err| {
+ log.warn("error refreshing global shortcuts: {}", .{err});
+ };
+ }
+
+ //---------------------------------------------------------------
+ // Signal Handlers
+
+ fn shortcutActivated(
+ _: *gio.DBusConnection,
+ _: ?[*:0]const u8,
+ _: [*:0]const u8,
+ _: [*:0]const u8,
+ _: [*:0]const u8,
+ params: *glib.Variant,
+ ud: ?*anyopaque,
+ ) callconv(.c) void {
+ const self: *Self = @ptrCast(@alignCast(ud));
+
+ // 2nd value in the tuple is the activated shortcut ID
+ // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-activated
+ var shortcut_id: [*:0]const u8 = undefined;
+ params.getChild(1, "&s", &shortcut_id);
+ log.debug("activated={s}", .{shortcut_id});
+
+ const action = self.private().map.get(std.mem.span(shortcut_id)) orelse return;
+ signals.trigger.impl.emit(
+ self,
+ null,
+ .{&action},
+ null,
+ );
+ }
+
+ //---------------------------------------------------------------
+ // Virtual methods
+
+ fn dispose(self: *Self) callconv(.c) void {
+ // Since we drop references here we may lose access to things like
+ // dbus connections, so we need to close all our connections right
+ // away instead of in finalize.
+ self.close();
+
+ const priv = self.private();
+ if (priv.config) |v| {
+ v.unref();
+ priv.config = null;
+ }
+ if (priv.dbus_connection) |v| {
+ v.unref();
+ priv.dbus_connection = null;
+ }
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.config.impl,
+ properties.@"dbus-connection".impl,
+ });
+
+ // Signals
+ signals.trigger.impl.register(.{});
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ };
+};
+
+const Token = [16]u8;
+
+/// Generate a random token suitable for use in requests.
+fn generateToken(buf: *Token) [:0]const u8 {
+ // u28 takes up 7 bytes in hex, 8 bytes for "ghostty_" and 1 byte for NUL
+ // 7 + 8 + 1 = 16
+ return std.fmt.bufPrintZ(
+ buf,
+ "ghostty_{x:0<7}",
+ .{std.crypto.random.int(u28)},
+ ) catch unreachable;
+}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig b/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig
new file mode 100644
index 00000000..0ef753a8
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig
@@ -0,0 +1,551 @@
+const std = @import("std");
+const assert = @import("../../../quirks.zig").inlineAssert;
+
+const cimgui = @import("dcimgui");
+const gl = @import("opengl");
+const adw = @import("adw");
+const gdk = @import("gdk");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const input = @import("../../../input.zig");
+const gresource = @import("../build/gresource.zig");
+
+const key = @import("../key.zig");
+const Common = @import("../class.zig").Common;
+
+const log = std.log.scoped(.gtk_ghostty_imgui_widget);
+
+/// A widget for embedding a Dear ImGui application.
+///
+/// It'd be a lot cleaner to use inheritance here but zig-gobject doesn't
+/// currently have a way to define virtual methods, so we have to use
+/// composition and signals instead.
+pub const ImguiWidget = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = adw.Bin;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyImguiWidget",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {};
+
+ pub const signals = struct {};
+
+ pub const virtual_methods = struct {
+ /// This virtual method will be called to allow the Dear ImGui
+ /// application to do one-time setup of the context. The correct context
+ /// will be current when the virtual method is called.
+ pub const setup = C.defineVirtualMethod("setup");
+
+ /// This virtual method will be called at each frame to allow the Dear
+ /// ImGui application to draw the application. The correct context will
+ /// be current when the virtual method is called.
+ pub const render = C.defineVirtualMethod("render");
+ };
+
+ const Private = struct {
+ /// GL area where we display the Dear ImGui application.
+ gl_area: *gtk.GLArea,
+
+ /// GTK input method context
+ im_context: *gtk.IMMulticontext,
+
+ /// Dear ImGui context. We create a context per widget so that we can
+ /// have multiple active imgui views in the same application.
+ ig_context: ?*cimgui.c.ImGuiContext = null,
+
+ /// Our previous instant used to calculate delta time for animations.
+ instant: ?std.time.Instant = null,
+
+ /// Tick callback ID for timed updates.
+ tick_callback_id: c_uint = 0,
+
+ /// Last render time for throttling to 30 FPS.
+ last_render_time: ?std.time.Instant = null,
+
+ pub var offset: c_int = 0;
+ };
+
+ //---------------------------------------------------------------
+ // Virtual Methods
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+ }
+
+ fn dispose(self: *Self) callconv(.c) void {
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ //---------------------------------------------------------------
+ // Public methods
+
+ /// This should be called anytime the underlying data for the UI changes
+ /// so that the UI can be refreshed.
+ pub fn queueRender(self: *ImguiWidget) void {
+ const priv = self.private();
+ priv.gl_area.queueRender();
+ }
+
+ //---------------------------------------------------------------
+ // Public wrappers for virtual methods
+
+ /// This virtual method will be called to allow the Dear ImGui application
+ /// to do one-time setup of the context. The correct context will be current
+ /// when the virtual method is called.
+ pub fn setup(self: *Self) callconv(.c) void {
+ const class = self.getClass() orelse return;
+ virtual_methods.setup.call(class, self, .{});
+ }
+
+ /// This virtual method will be called at each frame to allow the Dear ImGui
+ /// application to draw the application. The correct context will be current
+ /// when the virtual method is called.
+ pub fn render(self: *Self) callconv(.c) void {
+ const class = self.getClass() orelse return;
+ virtual_methods.render.call(class, self, .{});
+ }
+
+ //---------------------------------------------------------------
+ // Private Methods
+
+ /// Set our imgui context to be current, or return an error. This must be
+ /// called before any Dear ImGui API calls so that they're made against
+ /// the proper context.
+ fn setCurrentContext(self: *Self) error{ContextNotInitialized}!void {
+ const priv = self.private();
+ const ig_context = priv.ig_context orelse {
+ log.warn("Dear ImGui context not initialized", .{});
+ return error.ContextNotInitialized;
+ };
+ cimgui.c.ImGui_SetCurrentContext(ig_context);
+ }
+
+ /// Initialize the frame. Expects that the context is already current.
+ fn newFrame(self: *Self) void {
+ const priv = self.private();
+ const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
+
+ // Determine our delta time
+ const now = std.time.Instant.now() catch unreachable;
+ io.DeltaTime = if (priv.instant) |prev| delta: {
+ const since_ns: f64 = @floatFromInt(now.since(prev));
+ const ns_per_s: f64 = @floatFromInt(std.time.ns_per_s);
+ const since_s: f32 = @floatCast(since_ns / ns_per_s);
+ break :delta @max(0.00001, since_s);
+ } else (1.0 / 60.0);
+
+ priv.instant = now;
+ }
+
+ /// Handle key press/release events.
+ fn keyEvent(
+ self: *ImguiWidget,
+ action: input.Action,
+ ec_key: *gtk.EventControllerKey,
+ keyval: c_uint,
+ _: c_uint,
+ gtk_mods: gdk.ModifierType,
+ ) bool {
+ self.queueRender();
+
+ self.setCurrentContext() catch return false;
+
+ const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
+
+ const mods = key.translateMods(gtk_mods);
+ cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftShift, mods.shift);
+ cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftCtrl, mods.ctrl);
+ cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftAlt, mods.alt);
+ cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftSuper, mods.super);
+
+ // If our keyval has a key, then we send that key event
+ if (key.keyFromKeyval(keyval)) |inputkey| {
+ if (inputkey.imguiKey()) |imgui_key| {
+ cimgui.c.ImGuiIO_AddKeyEvent(io, imgui_key, action == .press);
+ }
+ }
+
+ // Try to process the event as text
+ if (ec_key.as(gtk.EventController).getCurrentEvent()) |event| {
+ const priv = self.private();
+ _ = priv.im_context.as(gtk.IMContext).filterKeypress(event);
+ }
+
+ return true;
+ }
+
+ /// Translate a GTK mouse button to a Dear ImGui mouse button.
+ fn translateMouseButton(button: c_uint) ?c_int {
+ return switch (button) {
+ 1 => cimgui.c.ImGuiMouseButton_Left,
+ 2 => cimgui.c.ImGuiMouseButton_Middle,
+ 3 => cimgui.c.ImGuiMouseButton_Right,
+ else => null,
+ };
+ }
+
+ /// Get the scale factor that the display is operating at.
+ fn getScaleFactor(self: *Self) f64 {
+ const priv = self.private();
+ return @floatFromInt(priv.gl_area.as(gtk.Widget).getScaleFactor());
+ }
+
+ //---------------------------------------------------------------
+ // Properties
+
+ //---------------------------------------------------------------
+ // Signal Handlers
+
+ fn glAreaRealize(_: *gtk.GLArea, self: *Self) callconv(.c) void {
+ const priv = self.private();
+ assert(priv.ig_context == null);
+
+ priv.gl_area.makeCurrent();
+ if (priv.gl_area.getError()) |err| {
+ log.warn("GLArea for Dear ImGui widget failed to realize: {s}", .{err.f_message orelse "(unknown)"});
+ return;
+ }
+
+ priv.ig_context = cimgui.c.ImGui_CreateContext(null) orelse {
+ log.warn("unable to initialize Dear ImGui context", .{});
+ return;
+ };
+ self.setCurrentContext() catch return;
+
+ // Setup some basic config
+ const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
+ io.BackendPlatformName = "ghostty_gtk";
+
+ // Realize means that our OpenGL context is ready, so we can now
+ // initialize the ImgUI OpenGL backend for our context.
+ _ = cimgui.ImGui_ImplOpenGL3_Init(null);
+
+ // Call the virtual method to setup the UI.
+ self.setup();
+
+ // Add a tick callback to drive timed updates via the frame clock.
+ priv.tick_callback_id = self.as(gtk.Widget).addTickCallback(
+ tickCallback,
+ null,
+ null,
+ );
+ }
+
+ /// Handle a request to unrealize the GLArea
+ fn glAreaUnrealize(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void {
+ const priv = self.private();
+ assert(priv.ig_context != null);
+
+ // Remove the tick callback if it was registered.
+ if (priv.tick_callback_id != 0) {
+ self.as(gtk.Widget).removeTickCallback(priv.tick_callback_id);
+ priv.tick_callback_id = 0;
+ }
+
+ // Unrealize is not guaranteed to be called with a current GL context,
+ // so we make it current for ImGui cleanup.
+ priv.gl_area.makeCurrent();
+ if (priv.gl_area.getError()) |err| {
+ log.warn("GLArea for Dear ImGui widget failed to realize: {s}", .{err.f_message orelse "(unknown)"});
+ return;
+ }
+
+ self.setCurrentContext() catch return;
+ cimgui.ImGui_ImplOpenGL3_ShutdownWithLoaderCleanup();
+ cimgui.c.ImGui_DestroyContext(priv.ig_context);
+ priv.ig_context = null;
+ }
+
+ /// Handle a request to resize the GLArea
+ fn glAreaResize(area: *gtk.GLArea, width: c_int, height: c_int, self: *Self) callconv(.c) void {
+ self.setCurrentContext() catch return;
+ const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
+ const scale_factor = area.as(gtk.Widget).getScaleFactor();
+
+ // Our display size is always unscaled. We'll do the scaling in the
+ // style instead. This creates crisper looking fonts.
+ io.DisplaySize = .{ .x = @floatFromInt(width), .y = @floatFromInt(height) };
+ io.DisplayFramebufferScale = .{ .x = 1, .y = 1 };
+
+ // Setup a new style and scale it appropriately. We must use the
+ // ImGuiStyle constructor to get proper default values (e.g.,
+ // CurveTessellationTol) rather than zero-initialized values.
+ var style: cimgui.c.ImGuiStyle = undefined;
+ cimgui.ext.ImGuiStyle_ImGuiStyle(&style);
+ cimgui.c.ImGuiStyle_ScaleAllSizes(&style, @floatFromInt(scale_factor));
+ const active_style = cimgui.c.ImGui_GetStyle();
+ active_style.* = style;
+ }
+
+ /// Handle a request to render the contents of our GLArea
+ fn glAreaRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Self) callconv(.c) c_int {
+ self.setCurrentContext() catch return @intFromBool(false);
+
+ // Update last render time for tick callback throttling.
+ const priv = self.private();
+ priv.last_render_time = std.time.Instant.now() catch null;
+
+ // Setup our frame. We render twice because some ImGui behaviors
+ // take multiple renders to process. I don't know how to make this
+ // more efficient.
+ for (0..2) |_| {
+ cimgui.ImGui_ImplOpenGL3_NewFrame();
+ self.newFrame();
+ cimgui.c.ImGui_NewFrame();
+
+ // Call the virtual method to draw the UI.
+ self.render();
+
+ // Render
+ cimgui.c.ImGui_Render();
+ }
+
+ // OpenGL final render
+ gl.clearColor(0x28 / 0xFF, 0x2C / 0xFF, 0x34 / 0xFF, 1.0);
+ gl.clear(gl.c.GL_COLOR_BUFFER_BIT);
+ cimgui.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.ImGui_GetDrawData());
+
+ return @intFromBool(true);
+ }
+
+ fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void {
+ self.setCurrentContext() catch return;
+ const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
+ cimgui.c.ImGuiIO_AddFocusEvent(io, true);
+ self.queueRender();
+ }
+
+ fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void {
+ self.setCurrentContext() catch return;
+ const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
+ cimgui.c.ImGuiIO_AddFocusEvent(io, false);
+ self.queueRender();
+ }
+
+ fn ecKeyPressed(
+ ec_key: *gtk.EventControllerKey,
+ keyval: c_uint,
+ keycode: c_uint,
+ gtk_mods: gdk.ModifierType,
+ self: *ImguiWidget,
+ ) callconv(.c) c_int {
+ return @intFromBool(self.keyEvent(
+ .press,
+ ec_key,
+ keyval,
+ keycode,
+ gtk_mods,
+ ));
+ }
+
+ fn ecKeyReleased(
+ ec_key: *gtk.EventControllerKey,
+ keyval: c_uint,
+ keycode: c_uint,
+ gtk_mods: gdk.ModifierType,
+ self: *ImguiWidget,
+ ) callconv(.c) void {
+ _ = self.keyEvent(
+ .release,
+ ec_key,
+ keyval,
+ keycode,
+ gtk_mods,
+ );
+ }
+
+ fn ecMousePressed(
+ gesture: *gtk.GestureClick,
+ _: c_int,
+ _: f64,
+ _: f64,
+ self: *ImguiWidget,
+ ) callconv(.c) void {
+ self.queueRender();
+ self.setCurrentContext() catch return;
+ const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
+ const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton();
+ if (translateMouseButton(gdk_button)) |button| {
+ cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, true);
+ }
+ }
+
+ fn ecMouseReleased(
+ gesture: *gtk.GestureClick,
+ _: c_int,
+ _: f64,
+ _: f64,
+ self: *ImguiWidget,
+ ) callconv(.c) void {
+ self.queueRender();
+ self.setCurrentContext() catch return;
+ const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
+ const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton();
+ if (translateMouseButton(gdk_button)) |button| {
+ cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, false);
+ }
+ }
+
+ fn ecMouseMotion(
+ _: *gtk.EventControllerMotion,
+ x: f64,
+ y: f64,
+ self: *ImguiWidget,
+ ) callconv(.c) void {
+ self.queueRender();
+ self.setCurrentContext() catch return;
+ const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
+ const scale_factor = self.getScaleFactor();
+ cimgui.c.ImGuiIO_AddMousePosEvent(
+ io,
+ @floatCast(x * scale_factor),
+ @floatCast(y * scale_factor),
+ );
+ }
+
+ fn ecMouseScroll(
+ _: *gtk.EventControllerScroll,
+ x: f64,
+ y: f64,
+ self: *ImguiWidget,
+ ) callconv(.c) c_int {
+ self.queueRender();
+ self.setCurrentContext() catch return @intFromBool(false);
+ const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
+ cimgui.c.ImGuiIO_AddMouseWheelEvent(
+ io,
+ @floatCast(x),
+ @floatCast(-y),
+ );
+ return @intFromBool(true);
+ }
+
+ fn imCommit(
+ _: *gtk.IMMulticontext,
+ bytes: [*:0]u8,
+ self: *ImguiWidget,
+ ) callconv(.c) void {
+ self.queueRender();
+ self.setCurrentContext() catch return;
+ const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
+ cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes);
+ }
+
+ /// Tick callback for timed updates. This drives periodic redraws.
+ /// Redraws are limited to 30 FPS max since our imgui widgets don't
+ /// usually need higher frame rates than that.
+ fn tickCallback(
+ widget: *gtk.Widget,
+ _: *gdk.FrameClock,
+ _: ?*anyopaque,
+ ) callconv(.c) c_int {
+ const self: *Self = gobject.ext.cast(Self, widget) orelse return 0;
+ const priv = self.private();
+
+ const now = std.time.Instant.now() catch {
+ self.queueRender();
+ return 1;
+ };
+
+ // Throttle to 30 FPS (~33ms between frames)
+ const frame_time_ns: u64 = std.time.ns_per_s / 30;
+ const should_render = if (priv.last_render_time) |last|
+ now.since(last) >= frame_time_ns
+ else
+ true;
+
+ if (should_render) self.queueRender();
+
+ return 1; // Continue the tick callback
+ }
+
+ //---------------------------------------------------------------
+ // Default virtual method handlers
+
+ /// Default setup function. Does nothing but log a warning.
+ fn defaultSetup(_: *Self) callconv(.c) void {
+ log.warn("default Dear ImGui setup called, this is a bug.", .{});
+ }
+
+ /// Default render function. Does nothing but log a warning.
+ fn defaultRender(_: *Self) callconv(.c) void {
+ log.warn("default Dear ImGui render called, this is a bug.", .{});
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const refSink = C.refSink;
+ pub const unref = C.unref;
+ pub const getClass = C.getClass;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+
+ /// Function pointers for virtual methods.
+ setup: ?*const fn (*Self) callconv(.c) void,
+ render: ?*const fn (*Self) callconv(.c) void,
+
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 5,
+ .name = "imgui-widget",
+ }),
+ );
+
+ // Initialize our virtual methods with default functions.
+ class.setup = defaultSetup;
+ class.render = defaultRender;
+
+ // Bindings
+ class.bindTemplateChildPrivate("gl_area", .{});
+ class.bindTemplateChildPrivate("im_context", .{});
+
+ // Template Callbacks
+ class.bindTemplateCallback("realize", &glAreaRealize);
+ class.bindTemplateCallback("unrealize", &glAreaUnrealize);
+ class.bindTemplateCallback("resize", &glAreaResize);
+ class.bindTemplateCallback("render", &glAreaRender);
+ class.bindTemplateCallback("focus_enter", &ecFocusEnter);
+ class.bindTemplateCallback("focus_leave", &ecFocusLeave);
+ class.bindTemplateCallback("key_pressed", &ecKeyPressed);
+ class.bindTemplateCallback("key_released", &ecKeyReleased);
+ class.bindTemplateCallback("mouse_pressed", &ecMousePressed);
+ class.bindTemplateCallback("mouse_released", &ecMouseReleased);
+ class.bindTemplateCallback("mouse_motion", &ecMouseMotion);
+ class.bindTemplateCallback("scroll", &ecMouseScroll);
+ class.bindTemplateCallback("im_commit", &imCommit);
+
+ // Signals
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/inspector_widget.zig b/vendor/libghostty-vt/src/apprt/gtk/class/inspector_widget.zig
new file mode 100644
index 00000000..80ac7fc3
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/inspector_widget.zig
@@ -0,0 +1,245 @@
+const std = @import("std");
+
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const gresource = @import("../build/gresource.zig");
+const Inspector = @import("../../../inspector/Inspector.zig");
+
+const Common = @import("../class.zig").Common;
+const Surface = @import("surface.zig").Surface;
+const ImguiWidget = @import("imgui_widget.zig").ImguiWidget;
+
+const log = std.log.scoped(.gtk_ghostty_inspector_widget);
+
+/// Widget for displaying the Ghostty inspector.
+pub const InspectorWidget = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = ImguiWidget;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyInspectorWidget",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ pub const surface = struct {
+ pub const name = "surface";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Surface,
+ .{
+ .accessor = .{
+ .getter = getSurfaceValue,
+ .setter = setSurfaceValue,
+ },
+ },
+ );
+ };
+ };
+
+ pub const signals = struct {};
+
+ const Private = struct {
+ /// The surface that we are attached to. This is NOT referenced.
+ /// We attach a weak notify to the object.
+ surface: ?*Surface = null,
+
+ pub var offset: c_int = 0;
+ };
+
+ //---------------------------------------------------------------
+ // Virtual Methods
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+ }
+
+ fn dispose(self: *Self) callconv(.c) void {
+ // Clear our surface so it deactivates the inspector.
+ self.setSurface(null);
+
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ /// Called to do initial setup of the UI.
+ fn imguiSetup(
+ _: *Self,
+ ) callconv(.c) void {
+ Inspector.setup();
+ }
+
+ /// Called for every frame to draw the UI.
+ fn imguiRender(
+ self: *Self,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const surface = priv.surface orelse return;
+ const core_surface = surface.core() orelse return;
+ const inspector = core_surface.inspector orelse return;
+ inspector.render(core_surface);
+ }
+
+ //---------------------------------------------------------------
+ // Public methods
+
+ /// Queue a render of the Dear ImGui widget.
+ pub fn queueRender(self: *Self) void {
+ self.as(ImguiWidget).queueRender();
+ }
+
+ //---------------------------------------------------------------
+ // Properties
+
+ fn getSurfaceValue(self: *Self, value: *gobject.Value) void {
+ gobject.ext.Value.set(
+ value,
+ self.private().surface,
+ );
+ }
+
+ fn setSurfaceValue(self: *Self, value: *const gobject.Value) void {
+ self.setSurface(gobject.ext.Value.get(
+ value,
+ ?*Surface,
+ ));
+ }
+
+ pub fn getSurface(self: *Self) ?*Surface {
+ return self.private().surface;
+ }
+
+ pub fn setSurface(self: *Self, surface_: ?*Surface) void {
+ const priv = self.private();
+
+ // Do nothing if we're not changing the value.
+ if (surface_ == priv.surface) return;
+
+ // Setup our notification to happen at the end because we're
+ // changing values no matter what.
+ self.as(gobject.Object).freezeNotify();
+ defer self.as(gobject.Object).thawNotify();
+ self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec);
+
+ // Deactivate the inspector on the old surface if it exists
+ // and set our value to null.
+ if (priv.surface) |old| old: {
+ priv.surface = null;
+
+ // Remove our weak ref
+ old.as(gobject.Object).weakUnref(
+ surfaceWeakNotify,
+ self,
+ );
+
+ // Deactivate the inspector
+ const core_surface = old.core() orelse break :old;
+ core_surface.deactivateInspector();
+ }
+
+ // Activate the inspector on the new surface.
+ const surface = surface_ orelse return;
+ const core_surface = surface.core() orelse return;
+ core_surface.activateInspector() catch |err| {
+ log.warn("failed to activate inspector err={}", .{err});
+ return;
+ };
+
+ // We use a weak reference on surface to determine if the surface
+ // was closed while our inspector was active.
+ surface.as(gobject.Object).weakRef(
+ surfaceWeakNotify,
+ self,
+ );
+
+ // Store our surface. We don't need to ref this because we setup
+ // the weak notify above.
+ priv.surface = surface;
+
+ self.queueRender();
+ }
+
+ //---------------------------------------------------------------
+ // Signal Handlers
+
+ fn surfaceWeakNotify(
+ ud: ?*anyopaque,
+ surface: *gobject.Object,
+ ) callconv(.c) void {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return));
+ const priv = self.private();
+
+ // The weak notify docs call out that we can specifically use the
+ // pointer values for comparison, but the objects themselves are unsafe.
+ if (@intFromPtr(priv.surface) != @intFromPtr(surface)) return;
+
+ // According to weak notify docs, "surface" is in the "dispose" state.
+ // Our surface doesn't clear the core surface until the "finalize"
+ // state so we should be able to safely access it here. We need to
+ // be really careful though.
+ const old = priv.surface orelse return;
+ const core_surface = old.core() orelse return;
+ core_surface.deactivateInspector();
+ priv.surface = null;
+ self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec);
+
+ // Note: in the future we should probably show some content on our
+ // window to note that the surface went away in case our embedding
+ // widget doesn't close itself. As I type this, our window closes
+ // immediately when the surface goes away so you don't see this, but
+ // for completeness sake we should clean this up.
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const refSink = C.refSink;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gobject.ext.ensureType(ImguiWidget);
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 5,
+ .name = "inspector-widget",
+ }),
+ );
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.surface.impl,
+ });
+
+ // Signals
+
+ // Virtual methods
+ ImguiWidget.virtual_methods.setup.implement(class, imguiSetup);
+ ImguiWidget.virtual_methods.render.implement(class, imguiRender);
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/inspector_window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/inspector_window.zig
new file mode 100644
index 00000000..739e7569
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/inspector_window.zig
@@ -0,0 +1,217 @@
+const std = @import("std");
+const build_config = @import("../../../build_config.zig");
+
+const adw = @import("adw");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const gresource = @import("../build/gresource.zig");
+
+const Common = @import("../class.zig").Common;
+const Surface = @import("surface.zig").Surface;
+const DebugWarning = @import("debug_warning.zig").DebugWarning;
+const InspectorWidget = @import("inspector_widget.zig").InspectorWidget;
+const WeakRef = @import("../weak_ref.zig").WeakRef;
+
+const log = std.log.scoped(.gtk_ghostty_inspector_window);
+
+/// Window for displaying the Ghostty inspector.
+pub const InspectorWindow = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = adw.ApplicationWindow;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyInspectorWindow",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ pub const surface = struct {
+ pub const name = "surface";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Surface,
+ .{
+ .accessor = .{
+ .getter = getSurfaceValue,
+ .setter = setSurfaceValue,
+ },
+ },
+ );
+ };
+
+ pub const debug = struct {
+ pub const name = "debug";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = build_config.is_debug,
+ .accessor = gobject.ext.typedAccessor(Self, bool, .{
+ .getter = struct {
+ pub fn getter(_: *Self) bool {
+ return build_config.is_debug;
+ }
+ }.getter,
+ }),
+ },
+ );
+ };
+ };
+
+ pub const signals = struct {};
+
+ const Private = struct {
+ /// The surface that we are attached to
+ surface: WeakRef(Surface) = .empty,
+
+ /// The embedded inspector widget.
+ inspector_widget: *InspectorWidget,
+
+ pub var offset: c_int = 0;
+ };
+
+ //---------------------------------------------------------------
+ // Virtual Methods
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+
+ // Add our dev CSS class if we're in debug mode.
+ if (comptime build_config.is_debug) {
+ self.as(gtk.Widget).addCssClass("devel");
+ }
+
+ // Set our window icon. We can't set this in the blueprint file
+ // because its dependent on the build config.
+ self.as(gtk.Window).setIconName(build_config.bundle_id);
+ }
+
+ fn dispose(self: *Self) callconv(.c) void {
+ // You MUST clear all weak refs in dispose, otherwise it causes
+ // memory corruption on dispose on the TARGET (weak referenced)
+ // object. The only way we caught this is via Valgrind. Its not a leak,
+ // its an invalid memory read. In practice, I found this sometimes
+ // caused hanging!
+ self.setSurface(null);
+
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ //---------------------------------------------------------------
+ // Public methods
+
+ pub fn new(surface: *Surface) *Self {
+ return gobject.ext.newInstance(Self, .{
+ .surface = surface,
+ });
+ }
+
+ /// Present the window.
+ pub fn present(self: *Self) void {
+ self.as(gtk.Window).present();
+ }
+
+ /// Queue a render of the embedded widget.
+ pub fn queueRender(self: *Self) void {
+ const priv = self.private();
+ priv.inspector_widget.queueRender();
+ }
+
+ //---------------------------------------------------------------
+ // Properties
+
+ fn setSurface(self: *Self, newvalue: ?*Surface) void {
+ const priv = self.private();
+ priv.surface.set(newvalue);
+ }
+
+ fn getSurfaceValue(self: *Self, value: *gobject.Value) void {
+ // Important: get() refs, so we take to not increase ref twice
+ gobject.ext.Value.take(
+ value,
+ self.private().surface.get(),
+ );
+ }
+
+ fn setSurfaceValue(self: *Self, value: *const gobject.Value) void {
+ self.setSurface(gobject.ext.Value.get(
+ value,
+ ?*Surface,
+ ));
+ }
+
+ //---------------------------------------------------------------
+ // Signal Handlers
+
+ fn propInspectorSurface(
+ inspector: *InspectorWidget,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ // If the inspector's surface went away, we destroy the window.
+ // The inspector has a weak notify on the surface so it knows
+ // if it goes nil.
+ if (inspector.getSurface() == null) {
+ self.as(gtk.Window).destroy();
+ }
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const refSink = C.refSink;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gobject.ext.ensureType(DebugWarning);
+ gobject.ext.ensureType(InspectorWidget);
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 5,
+ .name = "inspector-window",
+ }),
+ );
+
+ // Template Bindings
+ class.bindTemplateChildPrivate("inspector_widget", .{});
+
+ // Template callbacks
+ class.bindTemplateCallback("notify_inspector_surface", &propInspectorSurface);
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.surface.impl,
+ properties.debug.impl,
+ });
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/key_state_overlay.zig b/vendor/libghostty-vt/src/apprt/gtk/class/key_state_overlay.zig
new file mode 100644
index 00000000..7aca8f01
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/key_state_overlay.zig
@@ -0,0 +1,342 @@
+const std = @import("std");
+const adw = @import("adw");
+const glib = @import("glib");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const ext = @import("../ext.zig");
+const gresource = @import("../build/gresource.zig");
+const Application = @import("application.zig").Application;
+const Common = @import("../class.zig").Common;
+
+const log = std.log.scoped(.gtk_ghostty_key_state_overlay);
+
+/// An overlay that displays the current key table stack and pending key sequence.
+/// This helps users understand what key bindings are active and what keys they've
+/// pressed in a multi-key sequence.
+pub const KeyStateOverlay = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = adw.Bin;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyKeyStateOverlay",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ pub const tables = struct {
+ pub const name = "tables";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*ext.StringList,
+ .{
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?*ext.StringList,
+ .{
+ .getter = getTables,
+ .getter_transfer = .none,
+ .setter = setTables,
+ .setter_transfer = .full,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const @"has-tables" = struct {
+ pub const name = "has-tables";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ bool,
+ .{ .getter = getHasTables },
+ ),
+ },
+ );
+ };
+
+ pub const sequence = struct {
+ pub const name = "sequence";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*ext.StringList,
+ .{
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?*ext.StringList,
+ .{
+ .getter = getSequence,
+ .getter_transfer = .none,
+ .setter = setSequence,
+ .setter_transfer = .full,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const @"has-sequence" = struct {
+ pub const name = "has-sequence";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ bool,
+ .{ .getter = getHasSequence },
+ ),
+ },
+ );
+ };
+
+ pub const @"valign-target" = struct {
+ pub const name = "valign-target";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ gtk.Align,
+ .{
+ .default = .end,
+ .accessor = C.privateShallowFieldAccessor("valign_target"),
+ },
+ );
+ };
+ };
+
+ const Private = struct {
+ /// The key table stack.
+ tables: ?*ext.StringList = null,
+
+ /// The key sequence.
+ sequence: ?*ext.StringList = null,
+
+ /// Target vertical alignment for the overlay.
+ valign_target: gtk.Align = .end,
+
+ pub var offset: c_int = 0;
+ };
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+ }
+
+ fn getTables(self: *Self) ?*ext.StringList {
+ return self.private().tables;
+ }
+
+ fn getSequence(self: *Self) ?*ext.StringList {
+ return self.private().sequence;
+ }
+
+ fn setTables(self: *Self, value: ?*ext.StringList) void {
+ const priv = self.private();
+ if (priv.tables) |old| {
+ old.destroy();
+ priv.tables = null;
+ }
+ if (value) |v| {
+ priv.tables = v;
+ }
+
+ self.as(gobject.Object).notifyByPspec(properties.tables.impl.param_spec);
+ self.as(gobject.Object).notifyByPspec(properties.@"has-tables".impl.param_spec);
+ }
+
+ fn setSequence(self: *Self, value: ?*ext.StringList) void {
+ const priv = self.private();
+ if (priv.sequence) |old| {
+ old.destroy();
+ priv.sequence = null;
+ }
+ if (value) |v| {
+ priv.sequence = v;
+ }
+
+ self.as(gobject.Object).notifyByPspec(properties.sequence.impl.param_spec);
+ self.as(gobject.Object).notifyByPspec(properties.@"has-sequence".impl.param_spec);
+ }
+
+ fn getHasTables(self: *Self) bool {
+ const v = self.private().tables orelse return false;
+ return v.strings.len > 0;
+ }
+
+ fn getHasSequence(self: *Self) bool {
+ const v = self.private().sequence orelse return false;
+ return v.strings.len > 0;
+ }
+
+ fn closureShowChevron(
+ _: *Self,
+ has_tables: bool,
+ has_sequence: bool,
+ ) callconv(.c) c_int {
+ return if (has_tables and has_sequence) 1 else 0;
+ }
+
+ fn closureHasState(
+ _: *Self,
+ has_tables: bool,
+ has_sequence: bool,
+ ) callconv(.c) c_int {
+ return if (has_tables or has_sequence) 1 else 0;
+ }
+
+ fn closureTablesText(
+ _: *Self,
+ tables: ?*ext.StringList,
+ ) callconv(.c) ?[*:0]const u8 {
+ const list = tables orelse return null;
+ if (list.strings.len == 0) return null;
+
+ var buf: std.Io.Writer.Allocating = .init(Application.default().allocator());
+ defer buf.deinit();
+
+ for (list.strings, 0..) |s, i| {
+ if (i > 0) buf.writer.writeAll(" > ") catch return null;
+ buf.writer.writeAll(s) catch return null;
+ }
+
+ return glib.ext.dupeZ(u8, buf.written());
+ }
+
+ fn closureSequenceText(
+ _: *Self,
+ sequence: ?*ext.StringList,
+ ) callconv(.c) ?[*:0]const u8 {
+ const list = sequence orelse return null;
+ if (list.strings.len == 0) return null;
+
+ var buf: std.Io.Writer.Allocating = .init(Application.default().allocator());
+ defer buf.deinit();
+
+ for (list.strings, 0..) |s, i| {
+ if (i > 0) buf.writer.writeAll(" ") catch return null;
+ buf.writer.writeAll(s) catch return null;
+ }
+
+ return glib.ext.dupeZ(u8, buf.written());
+ }
+
+ //---------------------------------------------------------------
+ // Template callbacks
+
+ fn onDragEnd(
+ _: *gtk.GestureDrag,
+ _: f64,
+ offset_y: f64,
+ self: *Self,
+ ) callconv(.c) void {
+ // Key state overlay only moves between top-center and bottom-center.
+ // Horizontal alignment is always center.
+ const priv = self.private();
+ const widget = self.as(gtk.Widget);
+ const parent = widget.getParent() orelse return;
+
+ const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight());
+ const self_height: f64 = @floatFromInt(widget.getAllocatedHeight());
+
+ const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height;
+ const new_y = self_y + offset_y + (self_height / 2);
+
+ const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start;
+
+ if (new_valign != priv.valign_target) {
+ priv.valign_target = new_valign;
+ self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec);
+ self.as(gtk.Widget).queueResize();
+ }
+ }
+
+ //---------------------------------------------------------------
+ // Virtual methods
+
+ fn dispose(self: *Self) callconv(.c) void {
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ const priv = self.private();
+
+ if (priv.tables) |v| {
+ v.destroy();
+ }
+ if (priv.sequence) |v| {
+ v.destroy();
+ }
+
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 2,
+ .name = "key-state-overlay",
+ }),
+ );
+
+ // Template Callbacks
+ class.bindTemplateCallback("on_drag_end", &onDragEnd);
+ class.bindTemplateCallback("show_chevron", &closureShowChevron);
+ class.bindTemplateCallback("has_state", &closureHasState);
+ class.bindTemplateCallback("tables_text", &closureTablesText);
+ class.bindTemplateCallback("sequence_text", &closureSequenceText);
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.tables.impl,
+ properties.@"has-tables".impl,
+ properties.sequence.impl,
+ properties.@"has-sequence".impl,
+ properties.@"valign-target".impl,
+ });
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/resize_overlay.zig b/vendor/libghostty-vt/src/apprt/gtk/class/resize_overlay.zig
new file mode 100644
index 00000000..e14f1563
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/resize_overlay.zig
@@ -0,0 +1,338 @@
+const std = @import("std");
+const adw = @import("adw");
+const glib = @import("glib");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const gresource = @import("../build/gresource.zig");
+const Common = @import("../class.zig").Common;
+
+const log = std.log.scoped(.gtk_ghostty_resize_overlay);
+
+/// The overlay that shows the current size while a surface is resizing.
+/// This can be used generically to show pretty much anything with a
+/// disappearing overlay, but we have no other use at this point so it
+/// is named specifically for what it does.
+///
+/// General usage:
+///
+/// 1. Add it to an overlay
+/// 2. Set the label with `setLabel`
+/// 3. Schedule to show it with `schedule`
+///
+/// Set any properties to change the behavior.
+pub const ResizeOverlay = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = adw.Bin;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyResizeOverlay",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ pub const duration = struct {
+ pub const name = "duration";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ c_uint,
+ .{
+ .default = 750,
+ .minimum = 250,
+ .maximum = std.math.maxInt(c_uint),
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "duration",
+ ),
+ },
+ );
+ };
+
+ pub const @"first-delay" = struct {
+ pub const name = "first-delay";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ c_uint,
+ .{
+ .default = 250,
+ .minimum = 250,
+ .maximum = std.math.maxInt(c_uint),
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "first_delay",
+ ),
+ },
+ );
+ };
+
+ pub const label = struct {
+ pub const name = "label";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = C.privateStringFieldAccessor("label_text"),
+ },
+ );
+ };
+
+ pub const @"overlay-halign" = struct {
+ pub const name = "overlay-halign";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ gtk.Align,
+ .{
+ .default = .center,
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "halign",
+ ),
+ },
+ );
+ };
+
+ pub const @"overlay-valign" = struct {
+ pub const name = "overlay-valign";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ gtk.Align,
+ .{
+ .default = .center,
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "valign",
+ ),
+ },
+ );
+ };
+ };
+
+ const Private = struct {
+ /// The label with the text
+ label: *gtk.Label,
+
+ /// The text to set on the label when scheduled.
+ label_text: ?[:0]const u8,
+
+ /// The time that the overlay appears.
+ duration: c_uint,
+
+ /// The first delay before any overlay is shown. Must be specified
+ /// during construction otherwise it has no effect.
+ first_delay: c_uint,
+
+ /// The idle source that we use to update the label.
+ idler: ?c_uint = null,
+
+ /// The timer for dismissing the overlay.
+ timer: ?c_uint = null,
+
+ /// The first delay timer.
+ delay_timer: ?c_uint = null,
+
+ /// The alignment of the label
+ halign: gtk.Align,
+ valign: gtk.Align,
+
+ pub var offset: c_int = 0;
+ };
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+
+ const priv = self.private();
+ if (priv.first_delay > 0) {
+ priv.delay_timer = glib.timeoutAdd(
+ priv.first_delay,
+ onDelayTimer,
+ self,
+ );
+ }
+ }
+
+ /// Set the label for the overlay. This will not show the
+ /// overlay if it is currently hidden; you must call schedule.
+ pub fn setLabel(self: *Self, label: ?[:0]const u8) void {
+ const priv = self.private();
+ if (priv.label_text) |v| glib.free(@ptrCast(@constCast(v)));
+ priv.label_text = null;
+ if (label) |v| priv.label_text = glib.ext.dupeZ(u8, v);
+ self.as(gobject.Object).notifyByPspec(properties.label.impl.param_spec);
+ }
+
+ /// Schedule the overlay to be shown. To avoid flickering during
+ /// resizes we schedule the overlay to be shown on the next idle tick.
+ pub fn schedule(self: *Self) void {
+ const priv = self.private();
+
+ // If we have a delay timer then we're not showing anything
+ // yet so do nothing.
+ if (priv.delay_timer != null) return;
+
+ // When updating a widget, wait until GTK is "idle", i.e. not in the middle
+ // of doing any other updates. Since we are called in the middle of resizing
+ // GTK is doing a lot of work rearranging all of the widgets. Not doing this
+ // results in a lot of warnings from GTK and _horrible_ flickering of the
+ // resize overlay.
+ if (priv.idler != null) return;
+ priv.idler = glib.idleAdd(onIdle, self);
+ }
+
+ fn onIdle(ud: ?*anyopaque) callconv(.c) c_int {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
+ const priv = self.private();
+
+ // No matter what our idler is complete with this callback
+ priv.idler = null;
+
+ // Cancel our previous show timer no matter what.
+ if (priv.timer) |timer| {
+ if (glib.Source.remove(timer) == 0) {
+ log.warn("unable to remove size overlay timer", .{});
+ }
+ priv.timer = null;
+ }
+
+ // If we have a label to show, show ourselves. If we don't have
+ // label text, then hide our label.
+ const text = priv.label_text orelse {
+ self.as(gtk.Widget).setVisible(0);
+ return 0;
+ };
+
+ // Set our label and show it.
+ priv.label.setLabel(text);
+ self.as(gtk.Widget).setVisible(1);
+
+ // Setup the new timer to hide ourselves after the delay.
+ priv.timer = glib.timeoutAdd(
+ priv.duration,
+ onTimer,
+ self,
+ );
+
+ return 0;
+ }
+
+ fn onTimer(ud: ?*anyopaque) callconv(.c) c_int {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
+ const priv = self.private();
+ priv.timer = null;
+ self.as(gtk.Widget).setVisible(0);
+ return 0;
+ }
+
+ fn onDelayTimer(ud: ?*anyopaque) callconv(.c) c_int {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
+ const priv = self.private();
+ priv.delay_timer = null;
+ return 0;
+ }
+
+ //---------------------------------------------------------------
+ // Virtual methods
+
+ fn dispose(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ if (priv.idler) |v| {
+ if (glib.Source.remove(v) == 0) {
+ log.warn("unable to remove resize overlay idler", .{});
+ }
+ priv.idler = null;
+ }
+ if (priv.timer) |v| {
+ if (glib.Source.remove(v) == 0) {
+ log.warn("unable to remove resize overlay timer", .{});
+ }
+ priv.timer = null;
+ }
+ if (priv.delay_timer) |v| {
+ if (glib.Source.remove(v) == 0) {
+ log.warn("unable to remove resize overlay delay timer", .{});
+ }
+ priv.delay_timer = null;
+ }
+
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ if (priv.label_text) |v| {
+ glib.free(@ptrCast(@constCast(v)));
+ priv.label_text = null;
+ }
+
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 2,
+ .name = "resize-overlay",
+ }),
+ );
+
+ // Bindings
+ class.bindTemplateChildPrivate("label", .{});
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.duration.impl,
+ properties.label.impl,
+ properties.@"first-delay".impl,
+ properties.@"overlay-halign".impl,
+ properties.@"overlay-valign".impl,
+ });
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/search_overlay.zig b/vendor/libghostty-vt/src/apprt/gtk/class/search_overlay.zig
new file mode 100644
index 00000000..a8111546
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/search_overlay.zig
@@ -0,0 +1,493 @@
+const std = @import("std");
+const adw = @import("adw");
+const glib = @import("glib");
+const gobject = @import("gobject");
+const gdk = @import("gdk");
+const gtk = @import("gtk");
+
+const gresource = @import("../build/gresource.zig");
+const Common = @import("../class.zig").Common;
+
+const log = std.log.scoped(.gtk_ghostty_search_overlay);
+
+/// The overlay that shows the current size while a surface is resizing.
+/// This can be used generically to show pretty much anything with a
+/// disappearing overlay, but we have no other use at this point so it
+/// is named specifically for what it does.
+///
+/// General usage:
+///
+/// 1. Add it to an overlay
+/// 2. Set the label with `setLabel`
+/// 3. Schedule to show it with `schedule`
+///
+/// Set any properties to change the behavior.
+pub const SearchOverlay = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = adw.Bin;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttySearchOverlay",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ pub const active = struct {
+ pub const name = "active";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ bool,
+ .{
+ .getter = getSearchActive,
+ .setter = setSearchActive,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const @"search-total" = struct {
+ pub const name = "search-total";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ u64,
+ .{
+ .default = 0,
+ .minimum = 0,
+ .maximum = std.math.maxInt(u64),
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ u64,
+ .{ .getter = getSearchTotal },
+ ),
+ },
+ );
+ };
+
+ pub const @"has-search-total" = struct {
+ pub const name = "has-search-total";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ bool,
+ .{ .getter = getHasSearchTotal },
+ ),
+ },
+ );
+ };
+
+ pub const @"search-selected" = struct {
+ pub const name = "search-selected";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ u64,
+ .{
+ .default = 0,
+ .minimum = 0,
+ .maximum = std.math.maxInt(u64),
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ u64,
+ .{ .getter = getSearchSelected },
+ ),
+ },
+ );
+ };
+
+ pub const @"has-search-selected" = struct {
+ pub const name = "has-search-selected";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ bool,
+ .{ .getter = getHasSearchSelected },
+ ),
+ },
+ );
+ };
+
+ pub const @"halign-target" = struct {
+ pub const name = "halign-target";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ gtk.Align,
+ .{
+ .default = .end,
+ .accessor = C.privateShallowFieldAccessor("halign_target"),
+ },
+ );
+ };
+
+ pub const @"valign-target" = struct {
+ pub const name = "valign-target";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ gtk.Align,
+ .{
+ .default = .start,
+ .accessor = C.privateShallowFieldAccessor("valign_target"),
+ },
+ );
+ };
+ };
+
+ pub const signals = struct {
+ /// Emitted when the search is stopped (e.g., Escape pressed).
+ pub const @"stop-search" = struct {
+ pub const name = "stop-search";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+
+ /// Emitted when the search text changes (debounced).
+ pub const @"search-changed" = struct {
+ pub const name = "search-changed";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{?[*:0]const u8},
+ void,
+ );
+ };
+
+ /// Emitted when navigating to the next match.
+ pub const @"next-match" = struct {
+ pub const name = "next-match";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+
+ /// Emitted when navigating to the previous match.
+ pub const @"previous-match" = struct {
+ pub const name = "previous-match";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+ };
+
+ const Private = struct {
+ /// The search entry widget.
+ search_entry: *gtk.SearchEntry,
+
+ /// True when a search is active, meaning we should show the overlay.
+ active: bool = false,
+
+ /// Total number of search matches (null means unknown/none).
+ search_total: ?usize = null,
+
+ /// Currently selected match index (null means none selected).
+ search_selected: ?usize = null,
+
+ /// Target horizontal alignment for the overlay.
+ halign_target: gtk.Align = .end,
+
+ /// Target vertical alignment for the overlay.
+ valign_target: gtk.Align = .start,
+
+ pub var offset: c_int = 0;
+ };
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+ }
+
+ /// Grab focus on the search entry and select all text.
+ pub fn grabFocus(self: *Self) void {
+ const priv = self.private();
+ _ = priv.search_entry.as(gtk.Widget).grabFocus();
+
+ // Select all text in the search entry field. -1 is distance from
+ // the end, causing the entire text to be selected.
+ priv.search_entry.as(gtk.Editable).selectRegion(0, -1);
+ }
+
+ // Set active status, and update search on activation
+ fn setSearchActive(self: *Self, active: bool) void {
+ const priv = self.private();
+ if (!priv.active and active) {
+ const text = priv.search_entry.as(gtk.Editable).getText();
+ signals.@"search-changed".impl.emit(self, null, .{text}, null);
+ }
+ priv.active = active;
+ }
+
+ // Set contents of search
+ pub fn setSearchContents(self: *Self, content: [:0]const u8) void {
+ const priv = self.private();
+ priv.search_entry.as(gtk.Editable).setText(content);
+ signals.@"search-changed".impl.emit(self, null, .{content}, null);
+ }
+
+ /// Set the total number of search matches.
+ pub fn setSearchTotal(self: *Self, total: ?usize) void {
+ const priv = self.private();
+ const had_total = priv.search_total != null;
+ if (priv.search_total == total) return;
+ priv.search_total = total;
+ self.as(gobject.Object).notifyByPspec(properties.@"search-total".impl.param_spec);
+ if (had_total != (total != null)) {
+ self.as(gobject.Object).notifyByPspec(properties.@"has-search-total".impl.param_spec);
+ }
+ }
+
+ /// Set the currently selected match index.
+ pub fn setSearchSelected(self: *Self, selected: ?usize) void {
+ const priv = self.private();
+ const had_selected = priv.search_selected != null;
+ if (priv.search_selected == selected) return;
+ priv.search_selected = selected;
+ self.as(gobject.Object).notifyByPspec(properties.@"search-selected".impl.param_spec);
+ if (had_selected != (selected != null)) {
+ self.as(gobject.Object).notifyByPspec(properties.@"has-search-selected".impl.param_spec);
+ }
+ }
+
+ fn getSearchActive(self: *Self) bool {
+ return self.private().active;
+ }
+
+ fn getSearchTotal(self: *Self) u64 {
+ return self.private().search_total orelse 0;
+ }
+
+ fn getHasSearchTotal(self: *Self) bool {
+ return self.private().search_total != null;
+ }
+
+ fn getSearchSelected(self: *Self) u64 {
+ return self.private().search_selected orelse 0;
+ }
+
+ fn getHasSearchSelected(self: *Self) bool {
+ return self.private().search_selected != null;
+ }
+
+ fn closureMatchLabel(
+ _: *Self,
+ has_selected: bool,
+ selected: u64,
+ has_total: bool,
+ total: u64,
+ ) callconv(.c) ?[*:0]const u8 {
+ if (!has_total or total == 0) return glib.ext.dupeZ(u8, "0/0");
+ var buf: [32]u8 = undefined;
+ const label = std.fmt.bufPrintZ(&buf, "{}/{}", .{
+ if (has_selected) selected + 1 else 0,
+ total,
+ }) catch return null;
+ return glib.ext.dupeZ(u8, label);
+ }
+
+ //---------------------------------------------------------------
+ // Template callbacks
+
+ fn searchChanged(entry: *gtk.SearchEntry, self: *Self) callconv(.c) void {
+ const text = entry.as(gtk.Editable).getText();
+ signals.@"search-changed".impl.emit(self, null, .{text}, null);
+ }
+
+ // NOTE: The callbacks below use anyopaque for the first parameter
+ // because they're shared with multiple widgets in the template.
+
+ fn stopSearch(_: *anyopaque, self: *Self) callconv(.c) void {
+ signals.@"stop-search".impl.emit(self, null, .{}, null);
+ }
+
+ fn nextMatch(_: *anyopaque, self: *Self) callconv(.c) void {
+ signals.@"next-match".impl.emit(self, null, .{}, null);
+ }
+
+ fn previousMatch(_: *anyopaque, self: *Self) callconv(.c) void {
+ signals.@"previous-match".impl.emit(self, null, .{}, null);
+ }
+
+ fn searchEntryKeyPressed(
+ _: *gtk.EventControllerKey,
+ keyval: c_uint,
+ _: c_uint,
+ gtk_mods: gdk.ModifierType,
+ self: *Self,
+ ) callconv(.c) c_int {
+ if (keyval == gdk.KEY_Return or keyval == gdk.KEY_KP_Enter) {
+ if (gtk_mods.shift_mask) {
+ signals.@"previous-match".impl.emit(self, null, .{}, null);
+ } else {
+ signals.@"next-match".impl.emit(self, null, .{}, null);
+ }
+
+ return 1;
+ }
+
+ return 0;
+ }
+
+ fn onDragEnd(
+ _: *gtk.GestureDrag,
+ offset_x: f64,
+ offset_y: f64,
+ self: *Self,
+ ) callconv(.c) void {
+ // On drag end, we want to move our halign/valign if we crossed
+ // the midpoint on either axis. This lets the search overlay be
+ // moved to different corners of the parent container.
+
+ const priv = self.private();
+ const widget = self.as(gtk.Widget);
+ const parent = widget.getParent() orelse return;
+
+ const parent_width: f64 = @floatFromInt(parent.getAllocatedWidth());
+ const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight());
+ const self_width: f64 = @floatFromInt(widget.getAllocatedWidth());
+ const self_height: f64 = @floatFromInt(widget.getAllocatedHeight());
+
+ const self_x: f64 = if (priv.halign_target == .start) 0 else parent_width - self_width;
+ const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height;
+
+ const new_x = self_x + offset_x + (self_width / 2);
+ const new_y = self_y + offset_y + (self_height / 2);
+
+ const new_halign: gtk.Align = if (new_x > parent_width / 2) .end else .start;
+ const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start;
+
+ var changed = false;
+ if (new_halign != priv.halign_target) {
+ priv.halign_target = new_halign;
+ self.as(gobject.Object).notifyByPspec(properties.@"halign-target".impl.param_spec);
+ changed = true;
+ }
+ if (new_valign != priv.valign_target) {
+ priv.valign_target = new_valign;
+ self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec);
+ changed = true;
+ }
+
+ if (changed) self.as(gtk.Widget).queueResize();
+ }
+
+ //---------------------------------------------------------------
+ // Virtual methods
+
+ fn dispose(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ _ = priv;
+
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ _ = priv;
+
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 2,
+ .name = "search-overlay",
+ }),
+ );
+
+ // Bindings
+ class.bindTemplateChildPrivate("search_entry", .{});
+
+ // Template Callbacks
+ class.bindTemplateCallback("stop_search", &stopSearch);
+ class.bindTemplateCallback("search_changed", &searchChanged);
+ class.bindTemplateCallback("match_label_closure", &closureMatchLabel);
+ class.bindTemplateCallback("next_match", &nextMatch);
+ class.bindTemplateCallback("previous_match", &previousMatch);
+ class.bindTemplateCallback("search_entry_key_pressed", &searchEntryKeyPressed);
+ class.bindTemplateCallback("on_drag_end", &onDragEnd);
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.active.impl,
+ properties.@"search-total".impl,
+ properties.@"has-search-total".impl,
+ properties.@"search-selected".impl,
+ properties.@"has-search-selected".impl,
+ properties.@"halign-target".impl,
+ properties.@"valign-target".impl,
+ });
+
+ // Signals
+ signals.@"stop-search".impl.register(.{});
+ signals.@"search-changed".impl.register(.{});
+ signals.@"next-match".impl.register(.{});
+ signals.@"previous-match".impl.register(.{});
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig
new file mode 100644
index 00000000..311fbd8a
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig
@@ -0,0 +1,1290 @@
+const std = @import("std");
+const assert = @import("../../../quirks.zig").inlineAssert;
+const Allocator = std.mem.Allocator;
+const adw = @import("adw");
+const gio = @import("gio");
+const glib = @import("glib");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const configpkg = @import("../../../config.zig");
+const apprt = @import("../../../apprt.zig");
+const ext = @import("../ext.zig");
+const gresource = @import("../build/gresource.zig");
+const Common = @import("../class.zig").Common;
+const WeakRef = @import("../weak_ref.zig").WeakRef;
+const Application = @import("application.zig").Application;
+const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
+const Surface = @import("surface.zig").Surface;
+const SurfaceScrolledWindow = @import("surface_scrolled_window.zig").SurfaceScrolledWindow;
+
+const log = std.log.scoped(.gtk_ghostty_split_tree);
+
+pub const SplitTree = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = gtk.Box;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttySplitTree",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ /// The active surface is the surface that should be receiving all
+ /// surface-targeted actions. This is usually the focused surface,
+ /// but may also not be focused if the user has selected a non-surface
+ /// widget.
+ pub const @"active-surface" = struct {
+ pub const name = "active-surface";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Surface,
+ .{
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?*Surface,
+ .{
+ .getter = getActiveSurface,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const @"has-surfaces" = struct {
+ pub const name = "has-surfaces";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ bool,
+ .{
+ .getter = getHasSurfaces,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const @"is-zoomed" = struct {
+ pub const name = "is-zoomed";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ bool,
+ .{
+ .getter = getIsZoomed,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const tree = struct {
+ pub const name = "tree";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Surface.Tree,
+ .{
+ .accessor = .{
+ .getter = getTreeValue,
+ .setter = setTreeValue,
+ },
+ },
+ );
+ };
+
+ pub const @"is-split" = struct {
+ pub const name = "is-split";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ bool,
+ .{
+ .getter = getIsSplit,
+ },
+ ),
+ },
+ );
+ };
+ };
+
+ pub const signals = struct {
+ /// Emitted whenever the tree property has changed, with access
+ /// to the previous and new values.
+ pub const changed = struct {
+ pub const name = "changed";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{ ?*const Surface.Tree, ?*const Surface.Tree },
+ void,
+ );
+ };
+ };
+
+ const Private = struct {
+ /// The tree datastructure containing all of our surface views.
+ tree: ?*Surface.Tree,
+
+ // Template bindings
+ tree_bin: *adw.Bin,
+
+ /// Last focused surface in the tree. We need this to handle various
+ /// tree change states.
+ last_focused: WeakRef(Surface) = .empty,
+
+ /// The source that we use to rebuild the tree. This is also
+ /// used to debounce updates.
+ rebuild_source: ?c_uint = null,
+
+ /// Used to store state about a pending surface close for the
+ /// close dialog.
+ pending_close: ?Surface.Tree.Node.Handle,
+
+ pub var offset: c_int = 0;
+ };
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+
+ // Initialize our actions
+ self.initActionMap();
+
+ // Initialize some basic state
+ const priv = self.private();
+ priv.pending_close = null;
+ }
+
+ fn initActionMap(self: *Self) void {
+ const s_variant_type = glib.ext.VariantType.newFor([:0]const u8);
+ defer s_variant_type.free();
+
+ const actions = [_]ext.actions.Action(Self){
+ // All of these will eventually take a target surface parameter.
+ // For now all our targets originate from the focused surface.
+ .init("new-split", actionNewSplit, s_variant_type),
+ .init("equalize", actionEqualize, null),
+ .init("zoom", actionZoom, null),
+ };
+
+ _ = ext.actions.addAsGroup(Self, self, "split-tree", &actions);
+ }
+
+ /// Create a new split in the given direction from the currently
+ /// active surface.
+ ///
+ /// If the tree is empty this will create a new tree with a new surface
+ /// and ignore the direction.
+ ///
+ /// The parent will be used as the parent of the surface regardless of
+ /// if that parent is in this split tree or not. This allows inheriting
+ /// surface properties from anywhere.
+ pub fn newSplit(
+ self: *Self,
+ direction: Surface.Tree.Split.Direction,
+ parent_: ?*Surface,
+ overrides: struct {
+ command: ?configpkg.Command = null,
+ working_directory: ?[:0]const u8 = null,
+ title: ?[:0]const u8 = null,
+
+ pub const none: @This() = .{};
+ },
+ ) Allocator.Error!void {
+ const alloc = Application.default().allocator();
+
+ // Create our new surface.
+ const surface: *Surface = .new(.{
+ .command = overrides.command,
+ .working_directory = overrides.working_directory,
+ .title = overrides.title,
+ });
+ defer surface.unref();
+ _ = surface.refSink();
+
+ // Inherit properly if we were asked to.
+ if (parent_) |p| {
+ if (p.core()) |core| {
+ surface.setParent(core, .split);
+ }
+ }
+
+ // Bind is-split property for new surface
+ _ = self.as(gobject.Object).bindProperty(
+ "is-split",
+ surface.as(gobject.Object),
+ "is-split",
+ .{ .sync_create = true },
+ );
+
+ // Create our tree
+ var single_tree = try Surface.Tree.init(alloc, surface);
+ defer single_tree.deinit();
+
+ // We want to move our focus to the new surface no matter what.
+ // But we need to be careful to restore state if we fail.
+ const old_last_focused = self.private().last_focused.get();
+ defer if (old_last_focused) |v| v.unref(); // unref strong ref from get
+ self.private().last_focused.set(surface);
+ errdefer self.private().last_focused.set(old_last_focused);
+
+ // If we have no tree yet, then this becomes our tree and we're done.
+ const old_tree = self.getTree() orelse {
+ self.setTree(&single_tree);
+ return;
+ };
+
+ // The handle we create the split relative to. Today this is the active
+ // surface but this might be the handle of the given parent if we want.
+ const handle = self.getActiveSurfaceHandle() orelse .root;
+
+ // Create our split!
+ var new_tree = try old_tree.split(
+ alloc,
+ handle,
+ direction,
+ 0.5, // Always split equally for new splits
+ &single_tree,
+ );
+ defer new_tree.deinit();
+ log.debug(
+ "new split at={} direction={} old_tree={f} new_tree={f}",
+ .{ handle, direction, old_tree, &new_tree },
+ );
+
+ // Replace our tree
+ self.setTree(&new_tree);
+ }
+
+ pub fn resize(
+ self: *Self,
+ direction: Surface.Tree.Split.Direction,
+ amount: u16,
+ ) Allocator.Error!bool {
+ // Avoid useless work
+ if (amount == 0) return false;
+
+ const old_tree = self.getTree() orelse return false;
+ const active = self.getActiveSurfaceHandle() orelse return false;
+
+ // Get all our dimensions we're going to need to turn our
+ // amount into a percentage.
+ const priv = self.private();
+ const width = priv.tree_bin.as(gtk.Widget).getWidth();
+ const height = priv.tree_bin.as(gtk.Widget).getHeight();
+ if (width == 0 or height == 0) return false;
+ const width_f64: f64 = @floatFromInt(width);
+ const height_f64: f64 = @floatFromInt(height);
+ const amount_f64: f64 = @floatFromInt(amount);
+
+ // Get our ratio and use positive/neg for directions.
+ const ratio: f64 = switch (direction) {
+ .right => amount_f64 / width_f64,
+ .left => -(amount_f64 / width_f64),
+ .down => amount_f64 / height_f64,
+ .up => -(amount_f64 / height_f64),
+ };
+
+ const layout: Surface.Tree.Split.Layout = switch (direction) {
+ .left, .right => .horizontal,
+ .up, .down => .vertical,
+ };
+
+ var new_tree = try old_tree.resize(
+ Application.default().allocator(),
+ active,
+ layout,
+ @floatCast(ratio),
+ );
+ defer new_tree.deinit();
+ self.setTree(&new_tree);
+ return true;
+ }
+
+ /// Move focus from the currently focused surface to the given
+ /// direction. Returns true if focus switched to a new surface.
+ pub fn goto(self: *Self, to: Surface.Tree.Goto) bool {
+ const tree = self.getTree() orelse return false;
+ const active = self.getActiveSurfaceHandle() orelse return false;
+ const target = if (tree.goto(
+ Application.default().allocator(),
+ active,
+ to,
+ )) |handle_|
+ handle_ orelse return false
+ else |err| switch (err) {
+ // Nothing we can do in this scenario. This is highly unlikely
+ // since split trees don't use that much memory. The application
+ // is probably about to crash in other ways.
+ error.OutOfMemory => return false,
+ };
+
+ // If we aren't changing targets then we did nothing.
+ if (active == target) return false;
+
+ // Get the surface at the target location and grab focus.
+ const surface = tree.nodes[target.idx()].leaf;
+ surface.grabFocus();
+
+ // We also need to setup our last_focused to this because if we
+ // trigger a tree change like below, the grab focus above never
+ // actually triggers in time to set this and this ensures we
+ // grab focus to the right thing.
+ const old_last_focused = self.private().last_focused.get();
+ defer if (old_last_focused) |v| v.unref(); // unref strong ref from get
+ self.private().last_focused.set(surface);
+ errdefer self.private().last_focused.set(old_last_focused);
+
+ if (tree.zoomed != null) {
+ const app = Application.default();
+ const config_obj = app.getConfig();
+ defer config_obj.unref();
+ const config = config_obj.get();
+
+ if (!config.@"split-preserve-zoom".navigation) {
+ tree.zoomed = null;
+ } else {
+ tree.zoom(target);
+ }
+
+ // When the zoom state changes our tree state changes and
+ // we need to send the proper notifications to trigger
+ // relayout.
+ const object = self.as(gobject.Object);
+ object.notifyByPspec(properties.tree.impl.param_spec);
+ object.notifyByPspec(properties.@"is-zoomed".impl.param_spec);
+ }
+
+ return true;
+ }
+
+ fn disconnectSurfaceHandlers(self: *Self) void {
+ const tree = self.getTree() orelse return;
+ var it = tree.iterator();
+ while (it.next()) |entry| {
+ const surface = entry.view;
+ _ = gobject.signalHandlersDisconnectMatched(
+ surface.as(gobject.Object),
+ .{ .data = true },
+ 0,
+ 0,
+ null,
+ null,
+ self,
+ );
+ }
+ }
+
+ fn connectSurfaceHandlers(self: *Self) void {
+ const tree = self.getTree() orelse return;
+ var it = tree.iterator();
+ while (it.next()) |entry| {
+ const surface = entry.view;
+ _ = Surface.signals.@"close-request".connect(
+ surface,
+ *Self,
+ surfaceCloseRequest,
+ self,
+ .{},
+ );
+ _ = gobject.Object.signals.notify.connect(
+ surface,
+ *Self,
+ propSurfaceFocused,
+ self,
+ .{ .detail = "focused" },
+ );
+ }
+ }
+
+ //---------------------------------------------------------------
+ // Properties
+
+ /// Returns true if this split tree needs confirmation before quitting based
+ /// on the various Ghostty configurations.
+ pub fn getNeedsConfirmQuit(self: *Self) bool {
+ const tree = self.getTree() orelse return false;
+ var it = tree.iterator();
+ while (it.next()) |entry| {
+ if (entry.view.core()) |core| {
+ if (core.needsConfirmQuit()) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /// Get the currently active surface. See the "active-surface" property.
+ /// This does not ref the value.
+ pub fn getActiveSurface(self: *Self) ?*Surface {
+ const tree = self.getTree() orelse return null;
+ const handle = self.getActiveSurfaceHandle() orelse return null;
+ return tree.nodes[handle.idx()].leaf;
+ }
+
+ fn getActiveSurfaceHandle(self: *Self) ?Surface.Tree.Node.Handle {
+ const tree = self.getTree() orelse return null;
+ var it = tree.iterator();
+ while (it.next()) |entry| {
+ if (entry.view.getFocused()) return entry.handle;
+ }
+
+ // If none are currently focused, the most previously focused
+ // surface (if it exists) is our active surface. This lets things
+ // like apprt actions and bell ringing continue to work in the
+ // background.
+ if (self.private().last_focused.get()) |v| {
+ defer v.unref();
+
+ // We need to find the handle of the last focused surface.
+ it = tree.iterator();
+ while (it.next()) |entry| {
+ if (entry.view == v) return entry.handle;
+ }
+ }
+
+ return null;
+ }
+
+ /// Returns the last focused surface in the tree.
+ pub fn getLastFocusedSurface(self: *Self) ?*Surface {
+ const surface = self.private().last_focused.get() orelse return null;
+ // We unref because get() refs the surface. We don't use the weakref
+ // in a multi-threaded context so this is safe.
+ surface.unref();
+ return surface;
+ }
+
+ pub fn getHasSurfaces(self: *Self) bool {
+ const tree: *const Surface.Tree = self.private().tree orelse &.empty;
+ return !tree.isEmpty();
+ }
+
+ pub fn getIsZoomed(self: *Self) bool {
+ const tree: *const Surface.Tree = self.private().tree orelse &.empty;
+ return tree.zoomed != null;
+ }
+
+ /// Get the tree data model that we're showing in this widget. This
+ /// does not clone the tree.
+ pub fn getTree(self: *Self) ?*Surface.Tree {
+ return self.private().tree;
+ }
+
+ /// Set the tree data model that we're showing in this widget. This
+ /// will clone the given tree.
+ pub fn setTree(self: *Self, tree_: ?*const Surface.Tree) void {
+ const priv = self.private();
+
+ // We always normalize our tree parameter so that empty trees
+ // become null so that we don't have to deal with callers being
+ // confused about that.
+ const tree: ?*const Surface.Tree = tree: {
+ const tree = tree_ orelse break :tree null;
+ if (tree.isEmpty()) break :tree null;
+ break :tree tree;
+ };
+
+ // Emit the signal so that handlers can witness both the before and
+ // after values of the tree.
+ signals.changed.impl.emit(
+ self,
+ null,
+ .{ priv.tree, tree },
+ null,
+ );
+
+ if (priv.tree) |old_tree| {
+ self.disconnectSurfaceHandlers();
+ ext.boxedFree(Surface.Tree, old_tree);
+ priv.tree = null;
+ }
+
+ if (tree) |new_tree| {
+ assert(priv.tree == null);
+ assert(!new_tree.isEmpty());
+ priv.tree = ext.boxedCopy(Surface.Tree, new_tree);
+ self.connectSurfaceHandlers();
+ }
+
+ self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec);
+ }
+
+ fn getTreeValue(self: *Self, value: *gobject.Value) void {
+ gobject.ext.Value.set(
+ value,
+ self.private().tree,
+ );
+ }
+
+ fn setTreeValue(self: *Self, value: *const gobject.Value) void {
+ self.setTree(gobject.ext.Value.get(
+ value,
+ ?*Surface.Tree,
+ ));
+ }
+
+ pub fn getIsSplit(self: *Self) bool {
+ const tree: *const Surface.Tree = self.private().tree orelse &.empty;
+ if (tree.isEmpty()) return false;
+
+ const root_handle: Surface.Tree.Node.Handle = .root;
+ const root = tree.nodes[root_handle.idx()];
+ return switch (root) {
+ .leaf => false,
+ .split => true,
+ };
+ }
+
+ //---------------------------------------------------------------
+ // Virtual methods
+
+ fn dispose(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ priv.last_focused.set(null);
+ if (priv.rebuild_source) |v| {
+ if (glib.Source.remove(v) == 0) {
+ log.warn("unable to remove rebuild source", .{});
+ }
+ priv.rebuild_source = null;
+ }
+
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ if (priv.tree) |tree| {
+ ext.boxedFree(Surface.Tree, tree);
+ priv.tree = null;
+ }
+
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ //---------------------------------------------------------------
+ // Signal handlers
+
+ pub fn actionNewSplit(
+ _: *gio.SimpleAction,
+ args_: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ const args = args_ orelse {
+ log.warn("split-tree.new-split called without a parameter", .{});
+ return;
+ };
+
+ var dir: ?[*:0]const u8 = null;
+ args.get("&s", &dir);
+
+ const direction = std.meta.stringToEnum(
+ Surface.Tree.Split.Direction,
+ std.mem.span(dir) orelse return,
+ ) orelse {
+ // Need to be defensive here since actions can be triggered externally.
+ log.warn("invalid split direction for split-tree.new-split: {s}", .{dir.?});
+ return;
+ };
+
+ self.newSplit(
+ direction,
+ self.getActiveSurface(),
+ .none,
+ ) catch |err| {
+ log.warn("new split failed error={}", .{err});
+ };
+ }
+
+ pub fn actionEqualize(
+ _: *gio.SimpleAction,
+ parameter_: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ _ = parameter_;
+
+ const old_tree = self.getTree() orelse return;
+ var new_tree = old_tree.equalize(Application.default().allocator()) catch |err| {
+ log.warn("unable to equalize tree: {}", .{err});
+ return;
+ };
+ defer new_tree.deinit();
+ self.setTree(&new_tree);
+ }
+
+ pub fn actionZoom(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ const tree = self.getTree() orelse return;
+ if (tree.zoomed != null) {
+ tree.zoomed = null;
+ } else {
+ const active = self.getActiveSurfaceHandle() orelse return;
+ if (tree.zoomed == active) return;
+ tree.zoom(active);
+ }
+
+ self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec);
+ }
+
+ fn surfaceCloseRequest(
+ surface: *Surface,
+ self: *Self,
+ ) callconv(.c) void {
+ const core = surface.core() orelse return;
+
+ // Reset our pending close state
+ const priv = self.private();
+ priv.pending_close = null;
+
+ // Find the surface in the tree to verify this is valid and
+ // set our pending close handle.
+ priv.pending_close = handle: {
+ const tree = self.getTree() orelse return;
+ var it = tree.iterator();
+ while (it.next()) |entry| {
+ if (entry.view == surface) {
+ break :handle entry.handle;
+ }
+ }
+
+ return;
+ };
+
+ // If we don't need to confirm then just close immediately.
+ if (!core.needsConfirmQuit()) {
+ closeConfirmationClose(
+ null,
+ self,
+ );
+ return;
+ }
+
+ // Show a confirmation dialog
+ const dialog: *CloseConfirmationDialog = .new(.surface);
+ _ = CloseConfirmationDialog.signals.@"close-request".connect(
+ dialog,
+ *Self,
+ closeConfirmationClose,
+ self,
+ .{},
+ );
+ dialog.present(self.as(gtk.Widget));
+ }
+
+ fn closeConfirmationClose(
+ _: ?*CloseConfirmationDialog,
+ self: *Self,
+ ) callconv(.c) void {
+ // Get the handle we're closing
+ const priv = self.private();
+ const handle = priv.pending_close orelse return;
+ priv.pending_close = null;
+
+ // Figure out our next focus target. The next focus target is
+ // always the "previous" surface unless we're the leftmost then
+ // its the next.
+ const old_tree = self.getTree() orelse return;
+ const next_focus: ?*Surface = next_focus: {
+ const alloc = Application.default().allocator();
+ const next_handle: Surface.Tree.Node.Handle =
+ (old_tree.goto(alloc, handle, .previous) catch null) orelse
+ (old_tree.goto(alloc, handle, .next) catch null) orelse
+ break :next_focus null;
+ if (next_handle == handle) break :next_focus null;
+
+ // Note: we don't need to ref this or anything because its
+ // guaranteed to remain in the new tree since its not part
+ // of the handle we're removing.
+ break :next_focus old_tree.nodes[next_handle.idx()].leaf;
+ };
+
+ // Remove it from the tree.
+ var new_tree = old_tree.remove(
+ Application.default().allocator(),
+ handle,
+ ) catch |err| {
+ log.warn("unable to remove surface from tree: {}", .{err});
+ return;
+ };
+ defer new_tree.deinit();
+ self.setTree(&new_tree);
+
+ // Grab focus. We have to set this on the "last focused" because our
+ // focus will be set when the tree is redrawn.
+ if (next_focus) |v| priv.last_focused.set(v);
+ }
+
+ fn propSurfaceFocused(
+ surface: *Surface,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ // We never CLEAR our last_focused because the property is specifically
+ // the last focused surface. We let the weakref clear itself when
+ // the surface is destroyed.
+ if (!surface.getFocused()) return;
+ self.private().last_focused.set(surface);
+
+ // Our active surface probably changed
+ self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
+ }
+
+ fn propTree(
+ self: *Self,
+ _: *gobject.ParamSpec,
+ _: ?*anyopaque,
+ ) callconv(.c) void {
+ const priv = self.private();
+
+ // No matter what we notify
+ self.as(gobject.Object).freezeNotify();
+ defer self.as(gobject.Object).thawNotify();
+ self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec);
+ self.as(gobject.Object).notifyByPspec(properties.@"is-zoomed".impl.param_spec);
+
+ // If we were planning a rebuild, always remove that so we can
+ // start from a clean slate.
+ if (priv.rebuild_source) |v| {
+ if (glib.Source.remove(v) == 0) {
+ log.warn("unable to remove rebuild source", .{});
+ }
+ priv.rebuild_source = null;
+ }
+
+ // If we transitioned to an empty tree, clear immediately instead of
+ // waiting for an idle callback. Delaying teardown can keep the last
+ // surface alive during shutdown if the main loop exits first.
+ if (priv.tree == null) {
+ priv.tree_bin.setChild(null);
+ return;
+ }
+
+ // Build on an idle callback so rapid tree changes are debounced.
+ // We keep the existing tree attached until the rebuild runs,
+ // which avoids transient empty frames.
+ assert(priv.rebuild_source == null);
+ priv.rebuild_source = glib.idleAdd(
+ onRebuild,
+ self,
+ );
+ }
+
+ fn onRebuild(ud: ?*anyopaque) callconv(.c) c_int {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
+
+ // Always mark our rebuild source as null since we're done.
+ const priv = self.private();
+ priv.rebuild_source = null;
+
+ // Rebuild our tree
+ const tree: *const Surface.Tree = self.private().tree orelse &.empty;
+ if (tree.isEmpty()) {
+ priv.tree_bin.setChild(null);
+ } else {
+ const built = self.buildTree(
+ tree,
+ tree.zoomed orelse .root,
+ );
+ defer built.deinit();
+ priv.tree_bin.setChild(built.widget);
+ }
+
+ // Replacing our tree widget hierarchy can reset focus state.
+ // If we have a last-focused surface, restore focus to it.
+ if (priv.last_focused.get()) |v| {
+ defer v.unref();
+ v.grabFocus();
+ }
+
+ // Our split status may have changed
+ self.as(gobject.Object).notifyByPspec(properties.@"is-split".impl.param_spec);
+
+ // Our active surface may have changed
+ self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
+
+ return 0;
+ }
+
+ /// Builds the widget tree associated with a surface split tree.
+ ///
+ /// Returned widgets are expected to be attached to a parent by the caller.
+ ///
+ /// If `release_ref` is true then `widget` has an extra temporary
+ /// reference that must be released once it is parented in the rebuilt
+ /// tree.
+ const BuildTreeResult = struct {
+ widget: *gtk.Widget,
+ release_ref: bool,
+
+ pub fn initNew(widget: *gtk.Widget) BuildTreeResult {
+ return .{ .widget = widget, .release_ref = false };
+ }
+
+ pub fn initReused(widget: *gtk.Widget) BuildTreeResult {
+ // We add a temporary ref to the widget to ensure it doesn't
+ // get destroyed while we're rebuilding the tree and detaching
+ // it from its old parent. The caller is expected to release
+ // this ref once the widget is attached to its new parent.
+ _ = widget.as(gobject.Object).ref();
+
+ // Detach after we ref it so that this doesn't mark the
+ // widget for destruction.
+ detachWidget(widget);
+
+ return .{ .widget = widget, .release_ref = true };
+ }
+
+ pub fn deinit(self: BuildTreeResult) void {
+ // If we have to release a ref, do it.
+ if (self.release_ref) self.widget.as(gobject.Object).unref();
+ }
+ };
+
+ fn buildTree(
+ self: *Self,
+ tree: *const Surface.Tree,
+ current: Surface.Tree.Node.Handle,
+ ) BuildTreeResult {
+ return switch (tree.nodes[current.idx()]) {
+ .leaf => |v| leaf: {
+ const window = ext.getAncestor(
+ SurfaceScrolledWindow,
+ v.as(gtk.Widget),
+ ) orelse {
+ // The surface isn't in a window already so we don't
+ // have to worry about reuse.
+ break :leaf .initNew(gobject.ext.newInstance(
+ SurfaceScrolledWindow,
+ .{ .surface = v },
+ ).as(gtk.Widget));
+ };
+
+ // Keep this widget alive while we detach it from the
+ // old tree and adopt it into the new one.
+ break :leaf .initReused(window.as(gtk.Widget));
+ },
+ .split => |s| split: {
+ const left = self.buildTree(tree, s.left);
+ defer left.deinit();
+ const right = self.buildTree(tree, s.right);
+ defer right.deinit();
+
+ break :split .initNew(SplitTreeSplit.new(
+ current,
+ &s,
+ left.widget,
+ right.widget,
+ ).as(gtk.Widget));
+ },
+ };
+ }
+
+ /// Detach a split widget from its current parent.
+ ///
+ /// We intentionally use parent-specific child APIs when possible
+ /// (`GtkPaned.setStartChild/setEndChild`, `AdwBin.setChild`) instead of
+ /// calling `gtk.Widget.unparent` directly. Container implementations track
+ /// child pointers/properties internally, and those setters are the path
+ /// that keeps container state and notifications in sync.
+ fn detachWidget(widget: *gtk.Widget) void {
+ const parent = widget.getParent() orelse return;
+
+ // Surface will be in a paned when it is split.
+ if (gobject.ext.cast(gtk.Paned, parent)) |paned| {
+ if (paned.getStartChild()) |child| {
+ if (child == widget) {
+ paned.setStartChild(null);
+ return;
+ }
+ }
+
+ if (paned.getEndChild()) |child| {
+ if (child == widget) {
+ paned.setEndChild(null);
+ return;
+ }
+ }
+ }
+
+ // Surface will be in a bin when it is not split.
+ if (gobject.ext.cast(adw.Bin, parent)) |bin| {
+ if (bin.getChild()) |child| {
+ if (child == widget) {
+ bin.setChild(null);
+ return;
+ }
+ }
+ }
+
+ // Fallback for unexpected parents where we don't have a typed
+ // container API available.
+ widget.unparent();
+ }
+
+ //---------------------------------------------------------------
+ // Class
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gobject.ext.ensureType(Surface);
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 5,
+ .name = "split-tree",
+ }),
+ );
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.@"active-surface".impl,
+ properties.@"has-surfaces".impl,
+ properties.@"is-zoomed".impl,
+ properties.tree.impl,
+ properties.@"is-split".impl,
+ });
+
+ // Bindings
+ class.bindTemplateChildPrivate("tree_bin", .{});
+
+ // Template Callbacks
+ class.bindTemplateCallback("notify_tree", &propTree);
+
+ // Signals
+ signals.changed.impl.register(.{});
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
+
+/// This is an internal-only widget that represents a split in the
+/// split tree. This is a wrapper around gtk.Paned that allows us to handle
+/// ratio (0 to 1) based positioning of the split, and also allows us to
+/// write back the updated ratio to the split tree when the user manually
+/// adjusts the split position.
+///
+/// Since this is internal, it expects to be nested within a SplitTree and
+/// will use `getAncestor` to find the SplitTree it belongs to.
+///
+/// This is an _immutable_ widget. It isn't meant to be updated after
+/// creation. As such, there are no properties or APIs to change the split,
+/// access the paned, etc.
+const SplitTreeSplit = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = adw.Bin;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttySplitTreeSplit",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ const Private = struct {
+ /// The handle of the node in the tree that this split represents.
+ /// Assumed to be correct.
+ handle: Surface.Tree.Node.Handle,
+
+ /// Source to handle repositioning the split when properties change.
+ idle: ?c_uint = null,
+
+ // Template bindings
+ paned: *gtk.Paned,
+
+ pub var offset: c_int = 0;
+ };
+
+ /// Create a new split.
+ ///
+ /// The reason we don't use GObject properties here is because this is
+ /// an immutable widget and we don't want to deal with the overhead of
+ /// all the boilerplate for properties, signals, bindings, etc.
+ pub fn new(
+ handle: Surface.Tree.Node.Handle,
+ split: *const Surface.Tree.Split,
+ start_child: *gtk.Widget,
+ end_child: *gtk.Widget,
+ ) *Self {
+ const self = gobject.ext.newInstance(Self, .{});
+ const priv = self.private();
+ priv.handle = handle;
+
+ // Setup our paned fields
+ const paned = priv.paned;
+ paned.setStartChild(start_child);
+ paned.setEndChild(end_child);
+ paned.as(gtk.Orientable).setOrientation(switch (split.layout) {
+ .horizontal => .horizontal,
+ .vertical => .vertical,
+ });
+
+ // Signals and so on are setup in the template.
+
+ return self;
+ }
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+ }
+
+ fn refresh(self: *Self) void {
+ const priv = self.private();
+ if (priv.idle == null) priv.idle = glib.idleAdd(
+ onIdle,
+ self,
+ );
+ }
+
+ fn onIdle(ud: ?*anyopaque) callconv(.c) c_int {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
+ const priv = self.private();
+ const paned = priv.paned;
+
+ // Our idle source is always over
+ priv.idle = null;
+
+ // Get our split. This is the most dangerous part of this entire
+ // widget. We assume that this widget is always a child of a
+ // SplitTree, we assume that our handle is valid, and we assume
+ // the handle is always a split node.
+ const split_tree = ext.getAncestor(
+ SplitTree,
+ self.as(gtk.Widget),
+ ) orelse return 0;
+ const tree = split_tree.getTree() orelse return 0;
+ const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split;
+
+ // Current, min, and max positions as pixels.
+ const pos = paned.getPosition();
+ const min = min: {
+ var val = gobject.ext.Value.new(c_int);
+ defer val.unset();
+ gobject.Object.getProperty(
+ paned.as(gobject.Object),
+ "min-position",
+ &val,
+ );
+ break :min gobject.ext.Value.get(&val, c_int);
+ };
+ const max = max: {
+ var val = gobject.ext.Value.new(c_int);
+ defer val.unset();
+ gobject.Object.getProperty(
+ paned.as(gobject.Object),
+ "max-position",
+ &val,
+ );
+ break :max gobject.ext.Value.get(&val, c_int);
+ };
+ const pos_set: bool = max: {
+ var val = gobject.ext.Value.new(c_int);
+ defer val.unset();
+ gobject.Object.getProperty(
+ paned.as(gobject.Object),
+ "position-set",
+ &val,
+ );
+ break :max gobject.ext.Value.get(&val, c_int) != 0;
+ };
+
+ // We don't actually use min, but we don't expect this to ever
+ // be non-zero, so let's add an assert to ensure that.
+ assert(min == 0);
+
+ // If our max is zero then we can't do any math. I don't know
+ // if this is possible but I suspect it can be if you make a nested
+ // split completely minimized.
+ if (max == 0) return 0;
+
+ // Determine our current ratio.
+ const current_ratio: f64 = ratio: {
+ const pos_f64: f64 = @floatFromInt(pos);
+ const max_f64: f64 = @floatFromInt(max);
+ break :ratio pos_f64 / max_f64;
+ };
+ const desired_ratio: f64 = @floatCast(split.ratio);
+
+ // If our ratio is close enough to our desired ratio, then
+ // we ignore the update. This is to avoid constant split updates
+ // for lossy floating point math.
+ if (std.math.approxEqAbs(
+ f64,
+ current_ratio,
+ desired_ratio,
+ 0.001,
+ )) {
+ return 0;
+ }
+
+ // If we're out of bounds, then we need to either set the position
+ // to what we expect OR update our expected ratio.
+
+ // If we've never set the position, then we set it to the desired.
+ if (!pos_set) {
+ const desired_pos: c_int = desired_pos: {
+ const max_f64: f64 = @floatFromInt(max);
+ break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio));
+ };
+ paned.setPosition(desired_pos);
+ return 0;
+ }
+
+ // If we've set the position, then this is a manual human update
+ // and we need to write our update back to the tree.
+ tree.resizeInPlace(priv.handle, @floatCast(current_ratio));
+
+ return 0;
+ }
+
+ //---------------------------------------------------------------
+ // Signal handlers
+
+ fn propPosition(
+ _: *gtk.Paned,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ self.refresh();
+ }
+
+ fn propMaxPosition(
+ _: *gtk.Paned,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ self.refresh();
+ }
+
+ fn propMinPosition(
+ _: *gtk.Paned,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ self.refresh();
+ }
+
+ //---------------------------------------------------------------
+ // Virtual methods
+
+ fn dispose(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ if (priv.idle) |v| {
+ if (glib.Source.remove(v) == 0) {
+ log.warn("unable to remove idle source", .{});
+ }
+ priv.idle = null;
+ }
+
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 5,
+ .name = "split-tree-split",
+ }),
+ );
+
+ // Bindings
+ class.bindTemplateChildPrivate("paned", .{});
+
+ // Template Callbacks
+ class.bindTemplateCallback("notify_max_position", &propMaxPosition);
+ class.bindTemplateCallback("notify_min_position", &propMinPosition);
+ class.bindTemplateCallback("notify_position", &propPosition);
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig
new file mode 100644
index 00000000..179c779d
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig
@@ -0,0 +1,4049 @@
+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 apprt = @import("../../../apprt.zig");
+const build_config = @import("../../../build_config.zig");
+const configpkg = @import("../../../config.zig");
+const datastruct = @import("../../../datastruct/main.zig");
+const font = @import("../../../font/main.zig");
+const input = @import("../../../input.zig");
+const internal_os = @import("../../../os/main.zig");
+const renderer = @import("../../../renderer.zig");
+const terminal = @import("../../../terminal/main.zig");
+const CoreSurface = @import("../../../Surface.zig");
+const gresource = @import("../build/gresource.zig");
+const ext = @import("../ext.zig");
+const gsettings = @import("../gsettings.zig");
+const gtk_key = @import("../key.zig");
+const ApprtSurface = @import("../Surface.zig");
+const Common = @import("../class.zig").Common;
+const Application = @import("application.zig").Application;
+const Config = @import("config.zig").Config;
+const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay;
+const SearchOverlay = @import("search_overlay.zig").SearchOverlay;
+const KeyStateOverlay = @import("key_state_overlay.zig").KeyStateOverlay;
+const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited;
+const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
+const TitleDialog = @import("title_dialog.zig").TitleDialog;
+const Window = @import("window.zig").Window;
+const InspectorWindow = @import("inspector_window.zig").InspectorWindow;
+const i18n = @import("../../../os/i18n.zig");
+const media = @import("../media.zig");
+
+const log = std.log.scoped(.gtk_ghostty_surface);
+
+pub const Surface = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = adw.Bin;
+ pub const Implements = [_]type{gtk.Scrollable};
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttySurface",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ .implements = &.{
+ gobject.ext.implement(gtk.Scrollable, .{}),
+ },
+ });
+
+ /// A SplitTree implementation that stores surfaces.
+ pub const Tree = datastruct.SplitTree(Self);
+
+ pub const properties = struct {
+ /// This property is set to true when the bell is ringing. Note that
+ /// this property will only emit a changed signal when there is a
+ /// full state change. If a bell is ringing and another bell event
+ /// comes through, the change notification will NOT be emitted.
+ ///
+ /// If you need to know every scenario the bell is triggered,
+ /// listen to the `bell` signal instead.
+ pub const @"bell-ringing" = struct {
+ pub const name = "bell-ringing";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = C.privateShallowFieldAccessor("bell_ringing"),
+ },
+ );
+ };
+
+ pub const config = struct {
+ pub const name = "config";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Config,
+ .{
+ .accessor = C.privateObjFieldAccessor("config"),
+ },
+ );
+ };
+
+ pub const @"child-exited" = struct {
+ pub const name = "child-exited";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "child_exited",
+ ),
+ },
+ );
+ };
+
+ pub const @"default-size" = struct {
+ pub const name = "default-size";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Size,
+ .{
+ .accessor = C.privateBoxedFieldAccessor("default_size"),
+ },
+ );
+ };
+
+ pub const @"error" = struct {
+ pub const name = "error";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "error",
+ ),
+ },
+ );
+ };
+
+ pub const @"font-size-request" = struct {
+ pub const name = "font-size-request";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*font.face.DesiredSize,
+ .{
+ .accessor = C.privateBoxedFieldAccessor("font_size_request"),
+ },
+ );
+ };
+
+ pub const focused = struct {
+ pub const name = "focused";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "focused",
+ ),
+ },
+ );
+ };
+
+ pub const @"min-size" = struct {
+ pub const name = "min-size";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Size,
+ .{
+ .accessor = C.privateBoxedFieldAccessor("min_size"),
+ },
+ );
+ };
+
+ pub const @"mouse-hidden" = struct {
+ pub const name = "mouse-hidden";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ bool,
+ .{
+ .getter = getMouseHidden,
+ .setter = setMouseHidden,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const @"mouse-shape" = struct {
+ pub const name = "mouse-shape";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ terminal.MouseShape,
+ .{
+ .default = .text,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ terminal.MouseShape,
+ .{
+ .getter = getMouseShape,
+ .setter = setMouseShape,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const @"mouse-hover-url" = struct {
+ pub const name = "mouse-hover-url";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = C.privateStringFieldAccessor("mouse_hover_url"),
+ },
+ );
+ };
+
+ pub const pwd = struct {
+ pub const name = "pwd";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = C.privateStringFieldAccessor("pwd"),
+ },
+ );
+ };
+
+ pub const title = struct {
+ pub const name = "title";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = C.privateStringFieldAccessor("title"),
+ },
+ );
+ };
+
+ pub const @"title-override" = struct {
+ pub const name = "title-override";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = C.privateStringFieldAccessor("title_override"),
+ },
+ );
+ };
+
+ pub const zoom = struct {
+ pub const name = "zoom";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "zoom",
+ ),
+ },
+ );
+ };
+
+ pub const @"is-split" = struct {
+ pub const name = "is-split";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "is_split",
+ ),
+ },
+ );
+ };
+
+ pub const hadjustment = struct {
+ pub const name = "hadjustment";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*gtk.Adjustment,
+ .{
+ .accessor = .{
+ .getter = getHAdjustmentValue,
+ .setter = setHAdjustmentValue,
+ },
+ },
+ );
+ };
+
+ pub const vadjustment = struct {
+ pub const name = "vadjustment";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*gtk.Adjustment,
+ .{
+ .accessor = .{
+ .getter = getVAdjustmentValue,
+ .setter = setVAdjustmentValue,
+ },
+ },
+ );
+ };
+
+ pub const @"hscroll-policy" = struct {
+ pub const name = "hscroll-policy";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ gtk.ScrollablePolicy,
+ .{
+ .default = .natural,
+ .accessor = C.privateShallowFieldAccessor("hscroll_policy"),
+ },
+ );
+ };
+
+ pub const @"vscroll-policy" = struct {
+ pub const name = "vscroll-policy";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ gtk.ScrollablePolicy,
+ .{
+ .default = .natural,
+ .accessor = C.privateShallowFieldAccessor("vscroll_policy"),
+ },
+ );
+ };
+
+ pub const @"key-sequence" = struct {
+ pub const name = "key-sequence";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*ext.StringList,
+ .{
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?*ext.StringList,
+ .{
+ .getter = getKeySequence,
+ .getter_transfer = .full,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const @"key-table" = struct {
+ pub const name = "key-table";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*ext.StringList,
+ .{
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?*ext.StringList,
+ .{
+ .getter = getKeyTable,
+ .getter_transfer = .full,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const readonly = struct {
+ pub const name = "readonly";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ bool,
+ .{
+ .getter = getReadonly,
+ },
+ ),
+ },
+ );
+ };
+ };
+
+ pub const signals = struct {
+ /// Emitted whenever the bell event is received. Unlike the
+ /// `bell-ringing` property, this is emitted every time the event
+ /// is received and not just on state changes.
+ pub const bell = struct {
+ pub const name = "bell";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+ /// Emitted whenever the surface would like to be closed for any
+ /// reason.
+ ///
+ /// The surface view does NOT handle its own close confirmation.
+ /// If there is a process alive then the boolean parameter will
+ /// specify it and the parent widget should handle this request.
+ ///
+ /// This signal lets the containing widget decide how closure works.
+ /// This lets this Surface widget be used as a split, tab, etc.
+ /// without it having to be aware of its own semantics.
+ pub const @"close-request" = struct {
+ pub const name = "close-request";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+
+ /// Emitted whenever the clipboard has been written.
+ pub const @"clipboard-write" = struct {
+ pub const name = "clipboard-write";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{
+ apprt.Clipboard,
+ [*:0]const u8,
+ },
+ void,
+ );
+ };
+
+ /// Emitted whenever the surface reads the clipboard.
+ pub const @"clipboard-read" = struct {
+ pub const name = "clipboard-read";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+
+ /// Emitted after the surface is initialized.
+ pub const init = struct {
+ pub const name = "init";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+
+ /// Emitted just prior to the context menu appearing.
+ pub const menu = struct {
+ pub const name = "menu";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+
+ /// Emitted when the focus wants to be brought to the top and
+ /// focused.
+ pub const @"present-request" = struct {
+ pub const name = "present-request";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+
+ /// Emitted when this surface requests its container to toggle its
+ /// fullscreen state.
+ pub const @"toggle-fullscreen" = struct {
+ pub const name = "toggle-fullscreen";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+
+ /// Emitted when this surface requests its container to toggle its
+ /// maximized state.
+ pub const @"toggle-maximize" = struct {
+ pub const name = "toggle-maximize";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+ };
+
+ const Private = struct {
+ /// The configuration that this surface is using.
+ config: ?*Config = null,
+
+ /// The default size for a window that embeds this surface.
+ default_size: ?*Size = null,
+
+ /// The minimum size for this surface. Embedders enforce this,
+ /// not the surface itself.
+ min_size: ?*Size = null,
+
+ /// The requested font size. This only applies to initialization
+ /// and has no effect later.
+ font_size_request: ?*font.face.DesiredSize = null,
+
+ /// The mouse shape to show for the surface.
+ mouse_shape: terminal.MouseShape = .default,
+
+ /// Whether the mouse should be hidden or not as requested externally.
+ mouse_hidden: bool = false,
+
+ /// The URL that the mouse is currently hovering over.
+ mouse_hover_url: ?[:0]const u8 = null,
+
+ /// The current working directory. This has to be reported externally,
+ /// usually by shell integration which then talks to libghostty
+ /// which triggers this property.
+ ///
+ /// If this is set prior to initialization then the surface will
+ /// start in this pwd. If it is set after, it has no impact on the
+ /// core surface.
+ pwd: ?[:0]const u8 = null,
+
+ /// The title of this surface, if any has been set.
+ title: ?[:0]const u8 = null,
+
+ /// The manually overridden title of this surface from `promptTitle`.
+ title_override: ?[:0]const u8 = null,
+
+ /// The current focus state of the terminal based on the
+ /// focus events.
+ focused: bool = true,
+
+ /// Whether this surface is "zoomed" or not. A zoomed surface
+ /// shows up taking the full bounds of a split view.
+ zoom: bool = false,
+
+ /// The GLAarea that renders the actual surface. This is a binding
+ /// to the template so it doesn't have to be unrefed manually.
+ gl_area: *gtk.GLArea,
+
+ /// The labels for the left/right sides of the URL hover tooltip.
+ url_left: *gtk.Label,
+ url_right: *gtk.Label,
+
+ /// The resize overlay
+ resize_overlay: *ResizeOverlay,
+
+ /// The search overlay
+ search_overlay: *SearchOverlay,
+
+ /// The key state overlay
+ key_state_overlay: *KeyStateOverlay,
+
+ /// The apprt Surface.
+ rt_surface: ApprtSurface = undefined,
+
+ /// The core surface backing this GTK surface. This starts out
+ /// null because it can't be initialized until there is an available
+ /// GLArea that is realized.
+ //
+ // NOTE(mitchellh): This is a limitation we should definitely remove
+ // at some point by modifying our OpenGL renderer for GTK to
+ // start in an unrealized state. There are other benefits to being
+ // able to initialize the surface early so we should aim for that,
+ // eventually.
+ core_surface: ?*CoreSurface = null,
+
+ /// Cached metrics for libghostty callbacks
+ size: apprt.SurfaceSize,
+ cursor_pos: apprt.CursorPos,
+
+ /// Various input method state. All related to key input.
+ in_keyevent: IMKeyEvent = .false,
+ im_context: *gtk.IMMulticontext,
+ im_composing: bool = false,
+ im_buf: [128]u8 = undefined,
+ im_len: u7 = 0,
+
+ /// True when we have a precision scroll in progress
+ precision_scroll: bool = false,
+
+ /// True when the child has exited.
+ child_exited: bool = false,
+
+ // Progress bar
+ progress_bar_timer: ?c_uint = null,
+
+ // True while the bell is ringing. This will be set to false (after
+ // true) under various scenarios, but can also manually be set to
+ // false by a parent widget.
+ bell_ringing: bool = false,
+
+ /// True if this surface is in an error state. This is currently
+ /// a simple boolean with no additional information on WHAT the
+ /// error state is, because we don't yet need it or use it. For now,
+ /// if this is true, then it means the terminal is non-functional.
+ @"error": bool = false,
+
+ /// The source that handles setting our child property.
+ idle_rechild: ?c_uint = null,
+
+ /// A weak reference to an inspector window.
+ inspector: ?*InspectorWindow = null,
+
+ // True if the current surface is a split, this is used to apply
+ // unfocused-split-* options
+ is_split: bool = false,
+
+ action_group: ?*gio.SimpleActionGroup = null,
+
+ // Gtk.Scrollable interface adjustments
+ hadj: ?*gtk.Adjustment = null,
+ vadj: ?*gtk.Adjustment = null,
+ hscroll_policy: gtk.ScrollablePolicy = .natural,
+ vscroll_policy: gtk.ScrollablePolicy = .natural,
+ vadj_signal_group: ?*gobject.SignalGroup = null,
+
+ // Key state tracking for key sequences and tables
+ key_sequence: std.ArrayListUnmanaged([:0]const u8) = .empty,
+ key_tables: std.ArrayListUnmanaged([:0]const u8) = .empty,
+
+ // Template binds
+ child_exited_overlay: *ChildExited,
+ context_menu: *gtk.PopoverMenu,
+ drop_target: *gtk.DropTarget,
+ progress_bar_overlay: *gtk.ProgressBar,
+ error_page: *adw.StatusPage,
+ terminal_page: *gtk.Overlay,
+
+ /// The context for this surface (window, tab, or split)
+ context: apprt.surface.NewSurfaceContext = .window,
+
+ /// Whether primary paste (middle-click paste) is enabled.
+ gtk_enable_primary_paste: bool = true,
+
+ /// True when a left mouse down was consumed purely for a focus change,
+ /// and the matching left mouse release should also be suppressed.
+ suppress_left_mouse_release: bool = false,
+
+ /// How much pending horizontal scroll do we have?
+ pending_horizontal_scroll: f64 = 0.0,
+
+ /// Timer to reset the amount of horizontal scroll if the user
+ /// stops scrolling.
+ pending_horizontal_scroll_reset: ?c_uint = null,
+
+ overrides: struct {
+ command: ?configpkg.Command = null,
+ working_directory: ?[:0]const u8 = null,
+
+ pub const none: @This() = .{};
+ } = .none,
+
+ pub var offset: c_int = 0;
+ };
+
+ pub fn new(overrides: struct {
+ command: ?configpkg.Command = null,
+ working_directory: ?[:0]const u8 = null,
+ title: ?[:0]const u8 = null,
+
+ pub const none: @This() = .{};
+ }) *Self {
+ const self = gobject.ext.newInstance(Self, .{
+ .@"title-override" = overrides.title,
+ });
+ const alloc = Application.default().allocator();
+ const priv: *Private = self.private();
+ priv.overrides = .{
+ .command = if (overrides.command) |c| c.clone(alloc) catch null else null,
+ .working_directory = if (overrides.working_directory) |wd| alloc.dupeZ(u8, wd) catch null else null,
+ };
+ return self;
+ }
+
+ pub fn core(self: *Self) ?*CoreSurface {
+ const priv = self.private();
+ return priv.core_surface;
+ }
+
+ pub fn rt(self: *Self) *ApprtSurface {
+ const priv = self.private();
+ return &priv.rt_surface;
+ }
+
+ /// Set the parent of this surface. This will extract the information
+ /// required to initialize this surface with the proper values but doesn't
+ /// retain any memory.
+ ///
+ /// If the surface is already realized this does nothing.
+ pub fn setParent(
+ self: *Self,
+ parent: *CoreSurface,
+ context: apprt.surface.NewSurfaceContext,
+ ) void {
+ const priv = self.private();
+
+ // This is a mistake! We can only set a parent before surface
+ // realization. We log this because this is probably a logic error.
+ if (priv.core_surface != null) {
+ log.warn("setParent called after surface is already realized", .{});
+ return;
+ }
+
+ // Store the context so initSurface can use it
+ priv.context = context;
+
+ // Setup our font size
+ const font_size_ptr = glib.ext.create(font.face.DesiredSize);
+ errdefer glib.ext.destroy(font_size_ptr);
+ font_size_ptr.* = parent.font_size;
+ priv.font_size_request = font_size_ptr;
+ self.as(gobject.Object).notifyByPspec(properties.@"font-size-request".impl.param_spec);
+
+ // Remainder needs a config. If there is no config we just assume
+ // we aren't inheriting any of these values.
+ if (priv.config) |config_obj| {
+ // Setup our cwd if configured to inherit
+ if (apprt.surface.shouldInheritWorkingDirectory(context, config_obj.get())) {
+ if (parent.rt_surface.surface.getPwd()) |pwd| {
+ priv.pwd = glib.ext.dupeZ(u8, pwd);
+ self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec);
+ }
+ }
+ }
+ }
+
+ /// Force the surface to redraw itself. Ghostty often will only redraw
+ /// the terminal in reaction to internal changes. If there are external
+ /// events that invalidate the surface, such as the widget moving parents,
+ /// then we should force a redraw.
+ pub fn redraw(self: *Self) void {
+ const priv = self.private();
+ priv.gl_area.queueRender();
+ }
+
+ /// Callback used to determine whether border should be shown around the
+ /// surface.
+ fn closureShouldBorderBeShown(
+ _: *Self,
+ config_: ?*Config,
+ bell_ringing_: c_int,
+ ) callconv(.c) c_int {
+ const bell_ringing = bell_ringing_ != 0;
+
+ // If the bell isn't ringing exit early because when the surface is
+ // first created there's a race between this code being run and the
+ // config being set on the surface. That way we don't overwhelm people
+ // with the warning that we issue if the config isn't set and overwhelm
+ // ourselves with large numbers of bug reports.
+ if (!bell_ringing) return @intFromBool(false);
+
+ const config = if (config_) |v| v.get() else {
+ log.warn("config unavailable for computing whether border should be shown, likely bug", .{});
+ return @intFromBool(false);
+ };
+
+ return @intFromBool(config.@"bell-features".border);
+ }
+
+ /// Callback used to determine whether unfocused-split-fill / unfocused-split-opacity
+ /// should be applied to the surface
+ fn closureShouldUnfocusedSplitBeShown(
+ _: *Self,
+ search_active: c_int,
+ focused: c_int,
+ is_split: c_int,
+ ) callconv(.c) c_int {
+ return @intFromBool(search_active == 0 and focused == 0 and is_split != 0);
+ }
+
+ pub fn toggleFullscreen(self: *Self) void {
+ signals.@"toggle-fullscreen".impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+ }
+
+ pub fn toggleMaximize(self: *Self) void {
+ signals.@"toggle-maximize".impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+ }
+
+ pub fn toggleCommandPalette(self: *Self) bool {
+ // TODO: pass the surface with the action
+ return self.as(gtk.Widget).activateAction("win.toggle-command-palette", null) != 0;
+ }
+
+ pub fn controlInspector(
+ self: *Self,
+ value: apprt.Action.Value(.inspector),
+ ) bool {
+ // Let's see if we have an inspector already.
+ const priv = self.private();
+ if (priv.inspector) |inspector| switch (value) {
+ .show => {},
+ // Our weak ref will set our private value to null
+ .toggle, .hide => inspector.as(gtk.Window).destroy(),
+ } else switch (value) {
+ .toggle, .show => {
+ const inspector = InspectorWindow.new(self);
+ inspector.present();
+ inspector.as(gobject.Object).weakRef(inspectorWeakNotify, self);
+ priv.inspector = inspector;
+ },
+
+ .hide => {},
+ }
+
+ return true;
+ }
+
+ /// Redraw our inspector, if there is one associated with this surface.
+ pub fn redrawInspector(self: *Self) void {
+ const priv = self.private();
+ if (priv.inspector) |v| v.queueRender();
+ }
+
+ /// Handle a key sequence action from the apprt.
+ pub fn keySequenceAction(
+ self: *Self,
+ value: apprt.action.KeySequence,
+ ) Allocator.Error!void {
+ const priv = self.private();
+ const alloc = Application.default().allocator();
+
+ self.as(gobject.Object).freezeNotify();
+ defer self.as(gobject.Object).thawNotify();
+ self.as(gobject.Object).notifyByPspec(properties.@"key-sequence".impl.param_spec);
+
+ switch (value) {
+ .trigger => |trigger| {
+ // Convert the trigger to a human-readable label
+ var buf: std.Io.Writer.Allocating = .init(alloc);
+ defer buf.deinit();
+ if (gtk_key.labelFromTrigger(&buf.writer, trigger)) |success| {
+ if (!success) return;
+ } else |_| return error.OutOfMemory;
+
+ // Make space
+ try priv.key_sequence.ensureUnusedCapacity(alloc, 1);
+
+ // Copy and append
+ const duped = try buf.toOwnedSliceSentinel(0);
+ errdefer alloc.free(duped);
+ priv.key_sequence.appendAssumeCapacity(duped);
+ },
+ .end => {
+ // Free all the stored strings and clear
+ for (priv.key_sequence.items) |s| alloc.free(s);
+ priv.key_sequence.clearAndFree(alloc);
+ },
+ }
+ }
+
+ /// Handle a key table action from the apprt.
+ pub fn keyTableAction(
+ self: *Self,
+ value: apprt.action.KeyTable,
+ ) Allocator.Error!void {
+ const priv = self.private();
+ const alloc = Application.default().allocator();
+
+ self.as(gobject.Object).freezeNotify();
+ defer self.as(gobject.Object).thawNotify();
+ self.as(gobject.Object).notifyByPspec(properties.@"key-table".impl.param_spec);
+
+ switch (value) {
+ .activate => |name| {
+ // Duplicate the name string and push onto stack
+ const duped = try alloc.dupeZ(u8, name);
+ errdefer alloc.free(duped);
+ try priv.key_tables.append(alloc, duped);
+ },
+ .deactivate => {
+ // Pop and free the top table
+ if (priv.key_tables.pop()) |s| alloc.free(s);
+ },
+ .deactivate_all => {
+ // Free all tables and clear
+ for (priv.key_tables.items) |s| alloc.free(s);
+ priv.key_tables.clearAndFree(alloc);
+ },
+ }
+ }
+
+ pub fn showOnScreenKeyboard(self: *Self, event: ?*gdk.Event) bool {
+ const priv = self.private();
+ return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0;
+ }
+
+ /// Set the scrollbar state for this surface. This will setup the
+ /// properties for our Gtk.Scrollable interface properly.
+ pub fn setScrollbar(self: *Self, scrollbar: terminal.Scrollbar) void {
+ // Update existing adjustment in-place. If we don't have an
+ // adjustment then we do nothing because we're not part of a
+ // scrolled window.
+ const vadj = self.getVAdjustment() orelse return;
+
+ // Check if values match existing adjustment and skip update if so
+ const value: f64 = @floatFromInt(scrollbar.offset);
+ const upper: f64 = @floatFromInt(scrollbar.total);
+ const page_size: f64 = @floatFromInt(scrollbar.len);
+
+ if (std.math.approxEqAbs(f64, vadj.getValue(), value, 0.001) and
+ std.math.approxEqAbs(f64, vadj.getUpper(), upper, 0.001) and
+ std.math.approxEqAbs(f64, vadj.getPageSize(), page_size, 0.001))
+ {
+ return;
+ }
+
+ // If we have a vadjustment we MUST have the signal group since
+ // it is setup in the prop handler.
+ const priv = self.private();
+ const group = priv.vadj_signal_group.?;
+
+ // During manual scrollbar changes from Ghostty core we don't
+ // want to emit value-changed signals so we block them. This would
+ // cause a waste of resources at best and infinite loops at worst.
+ group.block();
+ defer group.unblock();
+
+ vadj.configure(
+ value, // value: current scroll position
+ 0, // lower: minimum value
+ upper, // upper: maximum value (total scrollable area)
+ 1, // step_increment: amount to scroll on arrow click
+ page_size, // page_increment: amount to scroll on page up/down
+ page_size, // page_size: size of visible area
+ );
+ }
+
+ /// Set the current progress report state.
+ pub fn setProgressReport(
+ self: *Self,
+ value: terminal.osc.Command.ProgressReport,
+ ) void {
+ const priv = self.private();
+
+ // No matter what, we stop the timer because if we're removing
+ // then we're done and otherwise we restart it.
+ if (priv.progress_bar_timer) |timer| {
+ if (glib.Source.remove(timer) == 0) {
+ log.warn("unable to remove progress bar timer", .{});
+ }
+ priv.progress_bar_timer = null;
+ }
+
+ if (priv.config) |config| {
+ if (!config.get().@"progress-style") {
+ log.debug("progress_report action blocked by config", .{});
+ priv.progress_bar_overlay.as(gtk.Widget).setVisible(@intFromBool(false));
+ return;
+ }
+ }
+
+ const progress_bar = priv.progress_bar_overlay;
+ switch (value.state) {
+ // Remove the progress bar
+ .remove => {
+ progress_bar.as(gtk.Widget).setVisible(@intFromBool(false));
+ return;
+ },
+
+ // Set the progress bar to a fixed value if one was provided, otherwise pulse.
+ // Remove the `error` CSS class so that the progress bar shows as normal.
+ .set => {
+ progress_bar.as(gtk.Widget).removeCssClass("error");
+ if (value.progress) |progress| {
+ progress_bar.setFraction(computeFraction(progress));
+ } else {
+ progress_bar.pulse();
+ }
+ },
+
+ // Set the progress bar to a fixed value if one was provided, otherwise pulse.
+ // Set the `error` CSS class so that the progress bar shows as an error color.
+ .@"error" => {
+ progress_bar.as(gtk.Widget).addCssClass("error");
+ if (value.progress) |progress| {
+ progress_bar.setFraction(computeFraction(progress));
+ } else {
+ progress_bar.pulse();
+ }
+ },
+
+ // The state of progress is unknown, so pulse the progress bar to
+ // indicate that things are still happening.
+ .indeterminate => {
+ progress_bar.pulse();
+ },
+
+ // If a progress value was provided, set the progress bar to that value.
+ // Don't pulse the progress bar as that would indicate that things were
+ // happening. Otherwise this is mainly used to keep the progress bar on
+ // screen instead of timing out.
+ .pause => {
+ if (value.progress) |progress| {
+ progress_bar.setFraction(computeFraction(progress));
+ }
+ },
+ }
+
+ // Assume all states lead to visibility
+ assert(value.state != .remove);
+ progress_bar.as(gtk.Widget).setVisible(@intFromBool(true));
+
+ // Start our timer to remove bad actor programs that stall
+ // the progress bar.
+ const progress_bar_timeout_seconds = 15;
+ assert(priv.progress_bar_timer == null);
+ priv.progress_bar_timer = glib.timeoutAdd(
+ progress_bar_timeout_seconds * std.time.ms_per_s,
+ progressBarTimer,
+ self,
+ );
+ }
+
+ /// The progress bar hasn't been updated by the TUI recently, remove it.
+ fn progressBarTimer(ud: ?*anyopaque) callconv(.c) c_int {
+ const self: *Self = @ptrCast(@alignCast(ud.?));
+ const priv = self.private();
+ priv.progress_bar_timer = null;
+ self.setProgressReport(.{ .state = .remove });
+ return @intFromBool(glib.SOURCE_REMOVE);
+ }
+
+ /// Request that this terminal come to the front and become focused.
+ /// It is up to the embedding widget to react to this.
+ pub fn present(self: *Self) void {
+ signals.@"present-request".impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+ }
+
+ pub fn commandFinished(self: *Self, value: apprt.Action.Value(.command_finished)) bool {
+ const app = Application.default();
+ const alloc = app.allocator();
+ const priv: *Private = self.private();
+
+ const notify_next_command_finish = notify: {
+ const simple_action_group = priv.action_group orelse break :notify false;
+ const action_group = simple_action_group.as(gio.ActionGroup);
+ const state = action_group.getActionState("notify-on-next-command-finish") orelse break :notify false;
+ const bool_variant_type = glib.ext.VariantType.newFor(bool);
+ defer bool_variant_type.free();
+ if (state.isOfType(bool_variant_type) == 0) break :notify false;
+ const notify = state.getBoolean() != 0;
+ action_group.changeActionState("notify-on-next-command-finish", glib.Variant.newBoolean(@intFromBool(false)));
+ break :notify notify;
+ };
+
+ const config = priv.config orelse return false;
+
+ const cfg = config.get();
+
+ if (!notify_next_command_finish) {
+ if (cfg.@"notify-on-command-finish" == .never) return true;
+ if (cfg.@"notify-on-command-finish" == .unfocused and self.getFocused()) return true;
+ }
+
+ if (value.duration.lte(cfg.@"notify-on-command-finish-after")) return true;
+
+ const action = cfg.@"notify-on-command-finish-action";
+
+ if (action.bell) self.setBellRinging(true);
+
+ if (action.notify) notify: {
+ const title_ = title: {
+ const exit_code = value.exit_code orelse break :title i18n._("Command Finished");
+ if (exit_code == 0) break :title i18n._("Command Succeeded");
+ break :title i18n._("Command Failed");
+ };
+ const title = std.mem.span(title_);
+ const body = body: {
+ const exit_code = value.exit_code orelse break :body std.fmt.allocPrintSentinel(
+ alloc,
+ "Command took {f}.",
+ .{value.duration.round(std.time.ns_per_ms)},
+ 0,
+ ) catch break :notify;
+ break :body std.fmt.allocPrintSentinel(
+ alloc,
+ "Command took {f} and exited with code {d}.",
+ .{ value.duration.round(std.time.ns_per_ms), exit_code },
+ 0,
+ ) catch break :notify;
+ };
+ defer alloc.free(body);
+
+ self.sendDesktopNotification(title, body);
+ }
+
+ return true;
+ }
+
+ /// Get the readonly state from the core surface.
+ pub fn getReadonly(self: *Self) bool {
+ const priv: *Private = self.private();
+ const surface = priv.core_surface orelse return false;
+ return surface.readonly;
+ }
+
+ /// Notify anyone interested that the readonly status has changed.
+ pub fn setReadonly(self: *Self, _: apprt.Action.Value(.readonly)) bool {
+ self.as(gobject.Object).notifyByPspec(properties.readonly.impl.param_spec);
+
+ return true;
+ }
+
+ /// Key press event (press or release).
+ ///
+ /// At a high level, we want to construct an `input.KeyEvent` and
+ /// pass that to `keyCallback`. At a low level, this is more complicated
+ /// than it appears because we need to construct all of this information
+ /// and its not given to us.
+ ///
+ /// For all events, we run the GdkEvent through the input method context.
+ /// This allows the input method to capture the event and trigger
+ /// callbacks such as preedit, commit, etc.
+ ///
+ /// There are a couple important aspects to the prior paragraph: we must
+ /// send ALL events through the input method context. This is because
+ /// input methods use both key press and key release events to determine
+ /// the state of the input method. For example, fcitx uses key release
+ /// events on modifiers (i.e. ctrl+shift) to switch the input method.
+ ///
+ /// We set some state to note we're in a key event (self.in_keyevent)
+ /// because some of the input method callbacks change behavior based on
+ /// this state. For example, we don't want to send character events
+ /// like "a" via the input "commit" event if we're actively processing
+ /// a keypress because we'd lose access to the keycode information.
+ /// However, a "commit" event may still happen outside of a keypress
+ /// event from e.g. a tablet or on-screen keyboard.
+ ///
+ /// Finally, we take all of the information in order to determine if we have
+ /// a unicode character or if we have to map the keyval to a code to
+ /// get the underlying logical key, etc.
+ ///
+ /// Then we can emit the keyCallback.
+ pub fn keyEvent(
+ self: *Surface,
+ action: input.Action,
+ ec_key: *gtk.EventControllerKey,
+ keyval: c_uint,
+ keycode: c_uint,
+ gtk_mods: gdk.ModifierType,
+ ) bool {
+ //log.warn("keyEvent action={}", .{action});
+ const event = ec_key.as(gtk.EventController).getCurrentEvent() orelse return false;
+ const key_event = gobject.ext.cast(gdk.KeyEvent, event) orelse return false;
+ const priv = self.private();
+
+ // The block below is all related to input method handling. See the function
+ // comment for some high level details and then the comments within
+ // the block for more specifics.
+ {
+ // This can trigger an input method so we need to notify the im context
+ // where the cursor is so it can render the dropdowns in the correct
+ // place.
+ if (priv.core_surface) |surface| {
+ const ime_point = surface.imePoint();
+ priv.im_context.as(gtk.IMContext).setCursorLocation(&.{
+ .f_x = @intFromFloat(ime_point.x),
+ .f_y = @intFromFloat(ime_point.y),
+ .f_width = 1,
+ .f_height = 1,
+ });
+ }
+
+ // We note that we're in a keypress because we want some logic to
+ // depend on this. For example, we don't want to send character events
+ // like "a" via the input "commit" event if we're actively processing
+ // a keypress because we'd lose access to the keycode information.
+ //
+ // We have to maintain some additional state here of whether we
+ // were composing because different input methods call the callbacks
+ // in different orders. For example, ibus calls commit THEN preedit
+ // end but simple calls preedit end THEN commit.
+ priv.in_keyevent = if (priv.im_composing) .composing else .not_composing;
+ defer priv.in_keyevent = .false;
+
+ // Pass the event through the input method which returns true if handled.
+ // Confusingly, not all events handled by the input method result
+ // in this returning true so we have to maintain some additional
+ // state about whether we were composing or not to determine if
+ // we should proceed with key encoding.
+ //
+ // Cases where the input method does not mark the event as handled:
+ //
+ // - If we change the input method via keypress while we have preedit
+ // text, the input method will commit the pending text but will not
+ // mark it as handled. We use the `.composing` state to detect
+ // this case.
+ //
+ // - If we switch input methods (i.e. via ctrl+shift with fcitx),
+ // the input method will handle the key release event but will not
+ // mark it as handled. I don't know any way to detect this case so
+ // it will result in a key event being sent to the key callback.
+ // For Kitty text encoding, this will result in modifiers being
+ // triggered despite being technically consumed. At the time of
+ // writing, both Kitty and Alacritty have the same behavior. I
+ // know of no way to fix this.
+ const im_handled = priv.im_context.as(gtk.IMContext).filterKeypress(event) != 0;
+ // log.warn("GTKIM: im_handled={} im_len={} im_composing={}", .{
+ // im_handled,
+ // self.im_len,
+ // self.im_composing,
+ // });
+
+ // If the input method handled the event, you would think we would
+ // never proceed with key encoding for Ghostty but that is not the
+ // case. Input methods will handle basic character encoding like
+ // typing "a" and we want to associate that with the key event.
+ // So we have to check additional state to determine if we exit.
+ if (im_handled) {
+ // If we are composing then we're in a preedit state and do
+ // not want to encode any keys. For example: type a deadkey
+ // such as single quote on a US international keyboard layout.
+ if (priv.im_composing) return true;
+
+ // If we were composing and now we're not, it means that we committed
+ // the text. We also don't want to encode a key event for this.
+ // Example: enable Japanese input method, press "konn" and then
+ // press enter. The final enter should not be encoded and "konn"
+ // (in hiragana) should be written as "こん".
+ if (priv.in_keyevent == .composing) return true;
+
+ // Not composing and our input method buffer is empty. This could
+ // mean that the input method reacted to this event by activating
+ // an onscreen keyboard or something equivalent. We don't know.
+ // But the input method handled it and didn't give us text so
+ // we will just assume we should not encode this. This handles a
+ // real scenario when ibus starts the emoji input method
+ // (super+.).
+ if (priv.im_len == 0) return true;
+ }
+
+ // At this point, for the sake of explanation of internal state:
+ // it is possible that im_len > 0 and im_composing == false. This
+ // means that we received a commit event from the input method that
+ // we want associated with the key event. This is common: its how
+ // basic character translation for simple inputs like "a" work.
+ }
+
+ // We always reset the length of the im buffer. There's only one scenario
+ // we reach this point with im_len > 0 and that's if we received a commit
+ // event from the input method. We don't want to keep that state around
+ // since we've handled it here.
+ defer priv.im_len = 0;
+
+ // Get the keyvals for this event.
+ const keyval_unicode = gdk.keyvalToUnicode(keyval);
+ const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted(
+ priv.gl_area.as(gtk.Widget),
+ key_event,
+ keycode,
+ );
+
+ // We want to get the physical unmapped key to process physical keybinds.
+ // (These are keybinds explicitly marked as requesting physical mapping).
+ const physical_key = keycode: {
+ const w3c_key: input.Key = w3c: for (input.keycodes.entries) |entry| {
+ if (entry.native == keycode) break :w3c entry.key;
+ } else .unidentified;
+
+ // Consult the pre-remapped XKB keyval/keysym to get the (possibly)
+ // remapped key. If the W3C key or the remapped key
+ // is eligible for remapping, we use it.
+ //
+ // See the docs for `shouldBeRemappable` for why we even have to
+ // do this in the first place.
+ if (gtk_key.keyFromKeyval(keyval)) |remapped| {
+ if (w3c_key.shouldBeRemappable() or remapped.shouldBeRemappable())
+ break :keycode remapped;
+ }
+
+ // Return the original physical key
+ break :keycode w3c_key;
+ };
+
+ // Get our modifier for the event
+ const mods: input.Mods = gtk_key.eventMods(
+ event,
+ physical_key,
+ gtk_mods,
+ action,
+ Application.default().winproto(),
+ );
+
+ // Get our consumed modifiers
+ const consumed_mods: input.Mods = consumed: {
+ const T = @typeInfo(gdk.ModifierType);
+ std.debug.assert(T.@"struct".layout == .@"packed");
+ const I = T.@"struct".backing_integer.?;
+
+ const masked = @as(I, @bitCast(key_event.getConsumedModifiers())) & @as(I, gdk.MODIFIER_MASK);
+ break :consumed gtk_key.translateMods(@bitCast(masked));
+ };
+
+ // log.debug("key pressed key={} keyval={x} physical_key={} composing={} text_len={} mods={}", .{
+ // key,
+ // keyval,
+ // physical_key,
+ // priv.im_composing,
+ // priv.im_len,
+ // mods,
+ // });
+
+ // If we have no UTF-8 text, we try to convert our keyval to
+ // a text value. We have to do this because GTK will not process
+ // "Ctrl+Shift+1" (on US keyboards) as "Ctrl+!" but instead as "".
+ // But the keyval is set correctly so we can at least extract that.
+ if (priv.im_len == 0 and keyval_unicode > 0) im: {
+ if (std.math.cast(u21, keyval_unicode)) |cp| {
+ // We don't want to send control characters as IM
+ // text. Control characters are handled already by
+ // the encoder directly.
+ if (cp < 0x20) break :im;
+
+ if (std.unicode.utf8Encode(cp, &priv.im_buf)) |len| {
+ priv.im_len = len;
+ } else |_| {}
+ }
+ }
+
+ // Invoke the core Ghostty logic to handle this input.
+ const surface = priv.core_surface orelse return false;
+ const effect = surface.keyCallback(.{
+ .action = action,
+ .key = physical_key,
+ .mods = mods,
+ .consumed_mods = consumed_mods,
+ .composing = priv.im_composing,
+ .utf8 = priv.im_buf[0..priv.im_len],
+ .unshifted_codepoint = keyval_unicode_unshifted,
+ }) catch |err| {
+ log.err("error in key callback err={}", .{err});
+ return false;
+ };
+
+ switch (effect) {
+ .closed => return true,
+ .ignored => {},
+ .consumed => if (action == .press or action == .repeat) {
+ // If we were in the composing state then we reset our context.
+ // We do NOT want to reset if we're not in the composing state
+ // because there is other IME state that we want to preserve,
+ // such as quotation mark ordering for Chinese input.
+ if (priv.im_composing) {
+ priv.im_context.as(gtk.IMContext).reset();
+ surface.preeditCallback(null) catch {};
+ }
+
+ // Bell stops ringing when any key is pressed that is used by
+ // the core in any way.
+ self.setBellRinging(false);
+
+ return true;
+ },
+ }
+
+ return false;
+ }
+
+ /// Prompt for a manual title change for the surface.
+ pub fn promptTitle(self: *Self) void {
+ const priv = self.private();
+ const dialog = TitleDialog.new(.surface, priv.title_override orelse priv.title);
+ _ = TitleDialog.signals.set.connect(
+ dialog,
+ *Self,
+ titleDialogSet,
+ self,
+ .{},
+ );
+
+ dialog.present(self.as(gtk.Widget));
+ }
+
+ /// Scale x/y by the GDK device scale.
+ fn scaledCoordinates(
+ self: *Self,
+ x: f64,
+ y: f64,
+ ) struct { x: f64, y: f64 } {
+ const gl_area = self.private().gl_area;
+ const scale_factor: f64 = @floatFromInt(
+ gl_area.as(gtk.Widget).getScaleFactor(),
+ );
+
+ return .{
+ .x = x * scale_factor,
+ .y = y * scale_factor,
+ };
+ }
+
+ //---------------------------------------------------------------
+ // Libghostty Callbacks
+
+ pub fn close(self: *Self) void {
+ signals.@"close-request".impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+ }
+
+ pub fn childExited(
+ self: *Self,
+ data: apprt.surface.Message.ChildExited,
+ ) bool {
+ // Even if we don't support the overlay, we still keep our property
+ // up to date for anyone listening.
+ const priv = self.private();
+ priv.child_exited = true;
+ self.as(gobject.Object).notifyByPspec(
+ properties.@"child-exited".impl.param_spec,
+ );
+
+ // If we have the noop child exited overlay then we don't do anything
+ // for child exited. The false return will force libghostty to show
+ // the normal text-based message.
+ if (comptime @hasDecl(ChildExited, "noop")) {
+ return false;
+ }
+
+ priv.child_exited_overlay.setData(&data);
+ return true;
+ }
+
+ pub fn getContentScale(self: *Self) apprt.ContentScale {
+ const priv = self.private();
+ const gl_area = priv.gl_area;
+
+ const gtk_scale: f32 = scale: {
+ const widget = gl_area.as(gtk.Widget);
+ // Future: detect GTK version 4.12+ and use gdk_surface_get_scale so we
+ // can support fractional scaling.
+ const scale = widget.getScaleFactor();
+ if (scale <= 0) {
+ log.warn("gtk_widget_get_scale_factor returned a non-positive number: {}", .{scale});
+ break :scale 1.0;
+ }
+ break :scale @floatFromInt(scale);
+ };
+
+ // Also scale using font-specific DPI, which is often exposed to the user
+ // via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html).
+ const xft_dpi_scale = xft_scale: {
+ // gtk-xft-dpi is font DPI multiplied by 1024. See
+ // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html
+ const gtk_xft_dpi = gsettings.get(.@"gtk-xft-dpi") orelse {
+ log.warn("gtk-xft-dpi was not set, using default value", .{});
+ break :xft_scale 1.0;
+ };
+
+ // Use a value of 1.0 for the XFT DPI scale if the setting is <= 0
+ // See:
+ // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/a7738a4d269bfdf4d8d5429ca73ccdd9b2450421
+ // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/9759d3fd81129608dd78116001928f2aed974ead
+ if (gtk_xft_dpi <= 0) {
+ log.warn("gtk-xft-dpi has invalid value ({}), using default", .{gtk_xft_dpi});
+ break :xft_scale 1.0;
+ }
+
+ // As noted above gtk-xft-dpi is multiplied by 1024, so we divide by
+ // 1024, then divide by the default value (96) to derive a scale. Note
+ // gtk-xft-dpi can be fractional, so we use floating point math here.
+ const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024.0;
+ break :xft_scale xft_dpi / 96.0;
+ };
+
+ const scale = gtk_scale * xft_dpi_scale;
+ return .{ .x = scale, .y = scale };
+ }
+
+ pub fn getSize(self: *Self) apprt.SurfaceSize {
+ const priv = self.private();
+ // By the time this is called, we should be in a widget tree.
+ // This should not be called before that. We ensure this by initializing
+ // the surface in `glareaResize`. This is VERY important because it
+ // avoids the pty having an incorrect initial size.
+ assert(priv.size.width >= 0 and priv.size.height >= 0);
+ return priv.size;
+ }
+
+ pub fn getCursorPos(self: *Self) apprt.CursorPos {
+ return self.private().cursor_pos;
+ }
+
+ pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap {
+ const app = Application.default();
+ const alloc = app.allocator();
+ var env = try internal_os.getEnvMap(alloc);
+ errdefer env.deinit();
+
+ if (app.savedLanguage()) |language| {
+ try env.put("LANG", language);
+ } else {
+ env.remove("LANG");
+ }
+
+ // Don't leak these GTK environment variables to child processes.
+ env.remove("GDK_DEBUG");
+ env.remove("GDK_DISABLE");
+ env.remove("GSK_RENDERER");
+
+ // Remove some environment variables that are set when Ghostty is launched
+ // from a `.desktop` file, by D-Bus activation, or systemd.
+ env.remove("GIO_LAUNCHED_DESKTOP_FILE");
+ env.remove("GIO_LAUNCHED_DESKTOP_FILE_PID");
+ env.remove("DBUS_STARTER_ADDRESS");
+ env.remove("DBUS_STARTER_BUS_TYPE");
+ env.remove("INVOCATION_ID");
+ env.remove("JOURNAL_STREAM");
+ env.remove("NOTIFY_SOCKET");
+
+ // Unset environment varies set by snaps if we're running in a snap.
+ // This allows Ghostty to further launch additional snaps.
+ if (comptime build_config.snap) {
+ if (env.get("SNAP") != null) try filterSnapPaths(
+ alloc,
+ &env,
+ );
+ }
+
+ // This is a hack because it ties ourselves (optionally) to the
+ // Window class. The right solution we should do is emit a signal
+ // here where the handler can modify our EnvMap, but boxing the
+ // EnvMap is a bit annoying so I'm punting it.
+ if (ext.getAncestor(Window, self.as(gtk.Widget))) |window| {
+ try window.winproto().addSubprocessEnv(&env);
+
+ if (window.isQuickTerminal()) {
+ try env.put("GHOSTTY_QUICK_TERMINAL", "1");
+ }
+ }
+
+ return env;
+ }
+
+ /// Filter out environment variables that start with forbidden prefixes.
+ fn filterSnapPaths(gpa: std.mem.Allocator, env_map: *std.process.EnvMap) !void {
+ comptime assert(build_config.snap);
+
+ const snap_vars = [_][]const u8{
+ "SNAP",
+ "SNAP_USER_COMMON",
+ "SNAP_USER_DATA",
+ "SNAP_DATA",
+ "SNAP_COMMON",
+ };
+
+ // Use an arena because everything in this function is temporary.
+ var arena = std.heap.ArenaAllocator.init(gpa);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var env_to_remove: std.ArrayList([]const u8) = .empty;
+ var env_to_update: std.ArrayList(struct {
+ key: []const u8,
+ value: []const u8,
+ }) = .empty;
+
+ var it = env_map.iterator();
+ while (it.next()) |entry| {
+ const key = entry.key_ptr.*;
+ const value = entry.value_ptr.*;
+
+ // Ignore fields we set ourself
+ if (std.mem.eql(u8, key, "TERMINFO")) continue;
+ if (std.mem.startsWith(u8, key, "GHOSTTY")) continue;
+
+ // Any env var starting with SNAP must be removed
+ if (std.mem.startsWith(u8, key, "SNAP_")) {
+ try env_to_remove.append(alloc, key);
+ continue;
+ }
+
+ var filtered_paths: std.ArrayList([]const u8) = .empty;
+ var modified = false;
+ var paths = std.mem.splitAny(u8, value, ":");
+ while (paths.next()) |path| {
+ var include = true;
+ for (snap_vars) |k| if (env_map.get(k)) |snap_path| {
+ if (snap_path.len == 0) continue;
+ if (std.mem.startsWith(u8, path, snap_path)) {
+ include = false;
+ modified = true;
+ break;
+ }
+ };
+ if (include) try filtered_paths.append(alloc, path);
+ }
+
+ if (modified) {
+ if (filtered_paths.items.len > 0) {
+ const new_value = try std.mem.join(alloc, ":", filtered_paths.items);
+ try env_to_update.append(alloc, .{ .key = key, .value = new_value });
+ } else {
+ try env_to_remove.append(alloc, key);
+ }
+ }
+ }
+
+ for (env_to_update.items) |item| try env_map.put(
+ item.key,
+ item.value,
+ );
+ for (env_to_remove.items) |key| _ = env_map.remove(key);
+ }
+
+ pub fn clipboardRequest(
+ self: *Self,
+ clipboard_type: apprt.Clipboard,
+ state: apprt.ClipboardRequest,
+ ) !bool {
+ return try Clipboard.request(
+ self,
+ clipboard_type,
+ state,
+ );
+ }
+
+ pub fn setClipboard(
+ self: *Self,
+ clipboard_type: apprt.Clipboard,
+ contents: []const apprt.ClipboardContent,
+ confirm: bool,
+ ) void {
+ Clipboard.set(
+ self,
+ clipboard_type,
+ contents,
+ confirm,
+ );
+ }
+
+ /// Focus this surface. This properly focuses the input part of
+ /// our surface.
+ pub fn grabFocus(self: *Self) void {
+ const priv = self.private();
+ _ = priv.gl_area.as(gtk.Widget).grabFocus();
+ }
+
+ pub fn sendDesktopNotification(self: *Self, title: [:0]const u8, body: [:0]const u8) void {
+ const app = Application.default();
+ const priv: *Private = self.private();
+
+ const core_surface = priv.core_surface orelse {
+ log.warn("can't send notification because there is no core surface", .{});
+ return;
+ };
+
+ const t = switch (title.len) {
+ 0 => "Ghostty",
+ else => title,
+ };
+
+ const notification = gio.Notification.new(t);
+ defer notification.unref();
+ notification.setBody(body);
+
+ const icon = gio.ThemedIcon.new("com.mitchellh.ghostty");
+ defer icon.unref();
+ notification.setIcon(icon.as(gio.Icon));
+
+ const pointer = glib.Variant.newUint64(core_surface.id);
+ 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 = app.as(gio.Application);
+ gio_app.sendNotification(body, notification);
+ }
+
+ //---------------------------------------------------------------
+ // Virtual Methods
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+
+ // Initialize our actions
+ self.initActionMap();
+
+ const priv = self.private();
+
+ // Initialize some private fields so they aren't undefined
+ priv.rt_surface = .{ .surface = self };
+ priv.precision_scroll = false;
+ priv.cursor_pos = .{ .x = 0, .y = 0 };
+ priv.mouse_shape = .text;
+ priv.mouse_hidden = false;
+ priv.focused = true;
+ priv.size = .{ .width = 0, .height = 0 };
+ priv.vadj_signal_group = null;
+
+ // If our configuration is null then we get the configuration
+ // from the application.
+ if (priv.config == null) {
+ const app = Application.default();
+ priv.config = app.getConfig();
+ }
+
+ // Setup our input method state
+ priv.in_keyevent = .false;
+ priv.im_composing = false;
+ priv.im_len = 0;
+
+ // Read GTK primary paste setting
+ priv.gtk_enable_primary_paste = gsettings.get(.@"gtk-enable-primary-paste") orelse true;
+
+ // Set up to handle items being dropped on our surface. Files can be dropped
+ // from Nautilus and strings can be dropped from many programs. The order
+ // of these types matter.
+ var drop_target_types = [_]gobject.Type{
+ gdk.FileList.getGObjectType(),
+ gio.File.getGObjectType(),
+ gobject.ext.types.string,
+ };
+ priv.drop_target.setGtypes(&drop_target_types, drop_target_types.len);
+
+ // Setup properties we can't set from our Blueprint file.
+ self.as(gtk.Widget).setCursorFromName("text");
+
+ // Initialize our config
+ self.propConfig(undefined, null);
+ }
+
+ fn initActionMap(self: *Self) void {
+ const priv: *Private = self.private();
+
+ const actions = [_]ext.actions.Action(Self){
+ .init(
+ "prompt-title",
+ actionPromptTitle,
+ null,
+ ),
+ .initStateful(
+ "notify-on-next-command-finish",
+ actionNotifyOnNextCommandFinish,
+ null,
+ glib.Variant.newBoolean(@intFromBool(false)),
+ ),
+ };
+
+ priv.action_group = ext.actions.addAsGroup(Self, self, "surface", &actions);
+ }
+
+ fn dispose(self: *Self) callconv(.c) void {
+ const priv = self.private();
+
+ if (priv.config) |v| {
+ v.unref();
+ priv.config = null;
+ }
+
+ if (priv.vadj_signal_group) |group| {
+ group.setTarget(null);
+ group.as(gobject.Object).unref();
+ priv.vadj_signal_group = null;
+ }
+
+ if (priv.hadj) |v| {
+ v.as(gobject.Object).unref();
+ priv.hadj = null;
+ }
+
+ if (priv.vadj) |v| {
+ v.as(gobject.Object).unref();
+ priv.vadj = null;
+ }
+
+ if (priv.progress_bar_timer) |timer| {
+ if (glib.Source.remove(timer) == 0) {
+ log.warn("unable to remove progress bar timer", .{});
+ }
+ priv.progress_bar_timer = null;
+ }
+
+ if (priv.idle_rechild) |v| {
+ if (glib.Source.remove(v) == 0) {
+ log.warn("unable to remove idle source", .{});
+ }
+ priv.idle_rechild = null;
+ }
+
+ if (priv.pending_horizontal_scroll_reset) |v| {
+ if (glib.Source.remove(v) == 0) {
+ log.warn("unable to remove pending horizontal scroll reset source", .{});
+ }
+ priv.pending_horizontal_scroll_reset = null;
+ }
+
+ // This works around a GTK double-free bug where if you bind
+ // to a top-level template child, it frees twice if the widget is
+ // also the root child of the template. By unsetting the child here,
+ // we avoid the double-free.
+ self.as(adw.Bin).setChild(null);
+
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ const alloc = Application.default().allocator();
+ const priv = self.private();
+ if (priv.core_surface) |v| {
+ // Remove ourselves from the list of known surfaces in the app.
+ // We do this before deinit in case a callback triggers
+ // searching for this surface.
+ Application.default().core().deleteSurface(self.rt());
+
+ // NOTE: We must deinit the surface in the finalize call and NOT
+ // the dispose call because the inspector widget relies on this
+ // behavior with a weakRef to properly deactivate.
+
+ // Deinit the surface
+ v.deinit();
+ alloc.destroy(v);
+
+ priv.core_surface = null;
+ }
+ if (priv.mouse_hover_url) |v| {
+ glib.free(@ptrCast(@constCast(v)));
+ priv.mouse_hover_url = null;
+ }
+ if (priv.default_size) |v| {
+ ext.boxedFree(Size, v);
+ priv.default_size = null;
+ }
+ if (priv.font_size_request) |v| {
+ glib.ext.destroy(v);
+ priv.font_size_request = null;
+ }
+ if (priv.min_size) |v| {
+ ext.boxedFree(Size, v);
+ priv.min_size = null;
+ }
+ if (priv.pwd) |v| {
+ glib.free(@ptrCast(@constCast(v)));
+ priv.pwd = null;
+ }
+ if (priv.title) |v| {
+ glib.free(@ptrCast(@constCast(v)));
+ priv.title = null;
+ }
+ if (priv.title_override) |v| {
+ glib.free(@ptrCast(@constCast(v)));
+ priv.title_override = null;
+ }
+ if (priv.overrides.command) |c| {
+ c.deinit(alloc);
+ priv.overrides.command = null;
+ }
+ if (priv.overrides.working_directory) |wd| {
+ alloc.free(wd);
+ priv.overrides.working_directory = null;
+ }
+
+ // Clean up key sequence and key table state
+ for (priv.key_sequence.items) |s| alloc.free(s);
+ priv.key_sequence.deinit(alloc);
+ for (priv.key_tables.items) |s| alloc.free(s);
+ priv.key_tables.deinit(alloc);
+
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ //---------------------------------------------------------------
+ // Properties
+
+ /// Returns the title property without a copy.
+ pub fn getTitle(self: *Self) ?[:0]const u8 {
+ return self.private().title;
+ }
+
+ /// Returns the effective title: the user-overridden title if set,
+ /// otherwise the terminal-set title.
+ pub fn getEffectiveTitle(self: *Self) ?[:0]const u8 {
+ const priv = self.private();
+ return priv.title_override orelse priv.title;
+ }
+
+ /// Copies the effective title to the clipboard.
+ pub fn copyTitleToClipboard(self: *Self) bool {
+ const title = self.getEffectiveTitle() orelse return false;
+ if (title.len == 0) return false;
+ self.setClipboard(.standard, &.{.{
+ .mime = "text/plain",
+ .data = title,
+ }}, false);
+ return true;
+ }
+
+ /// Set the title for this surface, copies the value. This should always
+ /// be the title as set by the terminal program, not any manually set
+ /// title. For manually set titles see `setTitleOverride`.
+ pub fn setTitle(self: *Self, title: ?[:0]const u8) void {
+ const priv = self.private();
+ if (priv.title) |v| glib.free(@ptrCast(@constCast(v)));
+ priv.title = null;
+ if (title) |v| priv.title = glib.ext.dupeZ(u8, v);
+ self.as(gobject.Object).notifyByPspec(properties.title.impl.param_spec);
+ }
+
+ /// Overridden title. This will be generally be shown over the title
+ /// unless this is unset (null).
+ pub fn setTitleOverride(self: *Self, title: ?[:0]const u8) void {
+ const priv = self.private();
+ if (priv.title_override) |v| glib.free(@ptrCast(@constCast(v)));
+ priv.title_override = null;
+ if (title) |v| priv.title_override = glib.ext.dupeZ(u8, v);
+ self.as(gobject.Object).notifyByPspec(properties.@"title-override".impl.param_spec);
+ }
+
+ /// Returns the pwd property without a copy.
+ pub fn getPwd(self: *Self) ?[:0]const u8 {
+ return self.private().pwd;
+ }
+
+ /// Set the pwd for this surface, copies the value.
+ pub fn setPwd(self: *Self, pwd: ?[:0]const u8) void {
+ const priv = self.private();
+ if (priv.pwd) |v| glib.free(@ptrCast(@constCast(v)));
+ priv.pwd = null;
+ if (pwd) |v| priv.pwd = glib.ext.dupeZ(u8, v);
+ self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec);
+ }
+
+ /// Returns the focus state of this surface.
+ pub fn getFocused(self: *Self) bool {
+ return self.private().focused;
+ }
+
+ /// Change the configuration for this surface.
+ pub fn setConfig(self: *Self, config: *Config) void {
+ const priv = self.private();
+ if (priv.config) |c| c.unref();
+ priv.config = config.ref();
+ self.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec);
+ }
+
+ /// Return the default size, if set.
+ pub fn getDefaultSize(self: *Self) ?*Size {
+ const priv = self.private();
+ return priv.default_size;
+ }
+
+ /// Set the default size for a window that contains this surface.
+ /// This is up to the embedding widget to respect this. Generally, only
+ /// the first surface in a window respects this.
+ pub fn setDefaultSize(self: *Self, size: Size) void {
+ const priv = self.private();
+ if (priv.default_size) |v| ext.boxedFree(
+ Size,
+ v,
+ );
+ priv.default_size = ext.boxedCopy(
+ Size,
+ &size,
+ );
+ self.as(gobject.Object).notifyByPspec(properties.@"default-size".impl.param_spec);
+ }
+
+ /// Estimate and set the initial window size from config and font metrics.
+ /// This can be called before the core surface exists to set up the window
+ /// size before presenting. This is an estimate because it does not take
+ /// into account any padding that may need to be added to the window.
+ pub fn estimateInitialSize(self: *Self) void {
+ const priv: *Private = self.private();
+ const config_obj = priv.config orelse return;
+ const config = config_obj.get();
+
+ // Both dimensions must be configured
+ if (config.@"window-height" <= 0 or config.@"window-width" <= 0) return;
+
+ const app = Application.default();
+ const alloc = app.allocator();
+
+ // Get content scale and compute DPI
+ const content_scale = self.getContentScale();
+ const x_dpi = content_scale.x * font.face.default_dpi;
+ const y_dpi = content_scale.y * font.face.default_dpi;
+
+ const font_size: font.face.DesiredSize = .{
+ .points = config.@"font-size",
+ .xdpi = @intFromFloat(x_dpi),
+ .ydpi = @intFromFloat(y_dpi),
+ };
+
+ // Get font grid for cell metrics
+ var derived_config = font.SharedGridSet.DerivedConfig.init(alloc, config) catch return;
+ defer derived_config.deinit();
+
+ const font_grid_key, const font_grid = app.core().font_grid_set.ref(
+ &derived_config,
+ font_size,
+ ) catch return;
+ defer app.core().font_grid_set.deref(font_grid_key);
+
+ const cell = font_grid.cellSize();
+
+ const width = @max(CoreSurface.min_window_width_cells, config.@"window-width") * cell.width;
+ const height = @max(CoreSurface.min_window_height_cells, config.@"window-height") * cell.height;
+ const width_f32: f32 = @floatFromInt(width);
+ const height_f32: f32 = @floatFromInt(height);
+
+ const final_width: u32 = @intFromFloat(@ceil(width_f32 / content_scale.x));
+ const final_height: u32 = @intFromFloat(@ceil(height_f32 / content_scale.y));
+
+ self.setDefaultSize(.{ .width = final_width, .height = final_height });
+ }
+
+ /// Get the key sequence list. Full transfer.
+ fn getKeySequence(self: *Self) ?*ext.StringList {
+ const priv = self.private();
+ const alloc = Application.default().allocator();
+ return ext.StringList.create(alloc, priv.key_sequence.items) catch null;
+ }
+
+ /// Get the key table list. Full transfer.
+ fn getKeyTable(self: *Self) ?*ext.StringList {
+ const priv = self.private();
+ const alloc = Application.default().allocator();
+ return ext.StringList.create(alloc, priv.key_tables.items) catch null;
+ }
+
+ /// Return the min size, if set.
+ pub fn getMinSize(self: *Self) ?*Size {
+ const priv = self.private();
+ return priv.min_size;
+ }
+
+ /// Set the min size for a window that contains this surface.
+ /// This is up to the embedding widget to respect this. Generally, only
+ /// the first surface in a window respects this.
+ pub fn setMinSize(self: *Self, size: Size) void {
+ const priv = self.private();
+ if (priv.min_size) |v| ext.boxedFree(
+ Size,
+ v,
+ );
+ priv.min_size = ext.boxedCopy(
+ Size,
+ &size,
+ );
+ self.as(gobject.Object).notifyByPspec(properties.@"min-size".impl.param_spec);
+ }
+
+ pub fn getMouseShape(self: *Self) terminal.MouseShape {
+ return self.private().mouse_shape;
+ }
+
+ pub fn setMouseShape(self: *Self, shape: terminal.MouseShape) void {
+ const priv = self.private();
+ priv.mouse_shape = shape;
+ self.as(gobject.Object).notifyByPspec(properties.@"mouse-shape".impl.param_spec);
+ }
+
+ pub fn getMouseHidden(self: *Self) bool {
+ return self.private().mouse_hidden;
+ }
+
+ pub fn setMouseHidden(self: *Self, hidden: bool) void {
+ const priv = self.private();
+ priv.mouse_hidden = hidden;
+ self.as(gobject.Object).notifyByPspec(properties.@"mouse-hidden".impl.param_spec);
+ }
+
+ pub fn setMouseHoverUrl(self: *Self, url: ?[:0]const u8) void {
+ const priv = self.private();
+ if (priv.mouse_hover_url) |v| glib.free(@ptrCast(@constCast(v)));
+ priv.mouse_hover_url = null;
+ if (url) |v| priv.mouse_hover_url = glib.ext.dupeZ(u8, v);
+ self.as(gobject.Object).notifyByPspec(properties.@"mouse-hover-url".impl.param_spec);
+ }
+
+ pub fn getBellRinging(self: *Self) bool {
+ return self.private().bell_ringing;
+ }
+
+ pub fn setBellRinging(self: *Self, ringing: bool) void {
+ // Prevent duplicate change notifications if the signals we emit
+ // in this function cause this state to change again.
+ self.as(gobject.Object).freezeNotify();
+ defer self.as(gobject.Object).thawNotify();
+
+ // Logic around bell reaction happens on every event even if we're
+ // already in the ringing state.
+ if (ringing) self.ringBell();
+
+ // Property change only happens on actual state change
+ const priv = self.private();
+ if (priv.bell_ringing == ringing) return;
+ priv.bell_ringing = ringing;
+ self.as(gobject.Object).notifyByPspec(properties.@"bell-ringing".impl.param_spec);
+ }
+
+ pub fn setError(self: *Self, v: bool) void {
+ const priv = self.private();
+ priv.@"error" = v;
+ self.as(gobject.Object).notifyByPspec(properties.@"error".impl.param_spec);
+ }
+
+ pub fn setSearchActive(self: *Self, active: bool, needle: [:0]const u8) void {
+ const priv = self.private();
+ var value = gobject.ext.Value.newFrom(active);
+ defer value.unset();
+ gobject.Object.setProperty(
+ priv.search_overlay.as(gobject.Object),
+ SearchOverlay.properties.active.name,
+ &value,
+ );
+
+ if (!std.mem.eql(u8, needle, "")) {
+ priv.search_overlay.setSearchContents(needle);
+ }
+
+ if (active) {
+ priv.search_overlay.grabFocus();
+ }
+ }
+
+ pub fn setSearchTotal(self: *Self, total: ?usize) void {
+ self.private().search_overlay.setSearchTotal(total);
+ }
+
+ pub fn setSearchSelected(self: *Self, selected: ?usize) void {
+ self.private().search_overlay.setSearchSelected(selected);
+ }
+
+ fn propConfig(
+ self: *Self,
+ _: *gobject.ParamSpec,
+ _: ?*anyopaque,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const config = if (priv.config) |c| c.get() else return;
+
+ // resize-overlay-duration
+ {
+ const ms = config.@"resize-overlay-duration".asMilliseconds();
+ var value = gobject.ext.Value.newFrom(ms);
+ defer value.unset();
+ gobject.Object.setProperty(
+ priv.resize_overlay.as(gobject.Object),
+ "duration",
+ &value,
+ );
+ }
+
+ // resize-overlay-position
+ {
+ const hv: struct {
+ gtk.Align, // halign
+ gtk.Align, // valign
+ } = switch (config.@"resize-overlay-position") {
+ .center => .{ .center, .center },
+ .@"top-left" => .{ .start, .start },
+ .@"top-right" => .{ .end, .start },
+ .@"top-center" => .{ .center, .start },
+ .@"bottom-left" => .{ .start, .end },
+ .@"bottom-right" => .{ .end, .end },
+ .@"bottom-center" => .{ .center, .end },
+ };
+
+ var halign = gobject.ext.Value.newFrom(hv[0]);
+ defer halign.unset();
+ var valign = gobject.ext.Value.newFrom(hv[1]);
+ defer valign.unset();
+ gobject.Object.setProperty(
+ priv.resize_overlay.as(gobject.Object),
+ "overlay-halign",
+ &halign,
+ );
+ gobject.Object.setProperty(
+ priv.resize_overlay.as(gobject.Object),
+ "overlay-valign",
+ &valign,
+ );
+ }
+ }
+
+ fn propError(
+ self: *Self,
+ _: *gobject.ParamSpec,
+ _: ?*anyopaque,
+ ) callconv(.c) void {
+ const priv = self.private();
+ if (priv.@"error") {
+ // Ensure we have an opaque background. The window will NOT set
+ // this if we have transparency set and we need an opaque
+ // background for the error message to be readable.
+ self.as(gtk.Widget).addCssClass("background");
+ } else {
+ // Regardless of transparency setting, we remove the background
+ // CSS class from this widget. Parent widgets will set it
+ // appropriately (see window.zig for example).
+ self.as(gtk.Widget).removeCssClass("background");
+ }
+
+ // We need to set our child property on an idle tick, because the
+ // error property can be triggered by signals that are in the middle
+ // of widget mapping and changing our child during that time
+ // results in a hard gtk crash.
+ if (priv.idle_rechild == null) priv.idle_rechild = glib.idleAdd(
+ onIdleRechild,
+ self,
+ );
+ }
+
+ fn onIdleRechild(ud: ?*anyopaque) callconv(.c) c_int {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
+ const priv = self.private();
+ priv.idle_rechild = null;
+ if (priv.@"error") {
+ self.as(adw.Bin).setChild(priv.error_page.as(gtk.Widget));
+ } else {
+ self.as(adw.Bin).setChild(priv.terminal_page.as(gtk.Widget));
+ }
+ return 0;
+ }
+
+ fn propMouseHoverUrl(
+ self: *Self,
+ _: *gobject.ParamSpec,
+ _: ?*anyopaque,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const visible = if (priv.mouse_hover_url) |v| v.len > 0 else false;
+ priv.url_left.as(gtk.Widget).setVisible(if (visible) 1 else 0);
+ }
+
+ fn propMouseHidden(
+ self: *Self,
+ _: *gobject.ParamSpec,
+ _: ?*anyopaque,
+ ) callconv(.c) void {
+ const priv = self.private();
+
+ // If we're hidden we set it to "none"
+ if (priv.mouse_hidden) {
+ self.as(gtk.Widget).setCursorFromName("none");
+ return;
+ }
+
+ // If we're not hidden we just trigger the mouse shape
+ // prop notification to handle setting the proper mouse shape.
+ self.propMouseShape(undefined, null);
+ }
+
+ fn propMouseShape(
+ self: *Self,
+ _: *gobject.ParamSpec,
+ _: ?*anyopaque,
+ ) callconv(.c) void {
+ const priv = self.private();
+
+ // If our mouse should be hidden currently then we don't
+ // do anything.
+ if (priv.mouse_hidden) return;
+
+ const name: [:0]const u8 = switch (priv.mouse_shape) {
+ .default => "default",
+ .help => "help",
+ .pointer => "pointer",
+ .context_menu => "context-menu",
+ .progress => "progress",
+ .wait => "wait",
+ .cell => "cell",
+ .crosshair => "crosshair",
+ .text => "text",
+ .vertical_text => "vertical-text",
+ .alias => "alias",
+ .copy => "copy",
+ .no_drop => "no-drop",
+ .move => "move",
+ .not_allowed => "not-allowed",
+ .grab => "grab",
+ .grabbing => "grabbing",
+ .all_scroll => "all-scroll",
+ .col_resize => "col-resize",
+ .row_resize => "row-resize",
+ .n_resize => "n-resize",
+ .e_resize => "e-resize",
+ .s_resize => "s-resize",
+ .w_resize => "w-resize",
+ .ne_resize => "ne-resize",
+ .nw_resize => "nw-resize",
+ .se_resize => "se-resize",
+ .sw_resize => "sw-resize",
+ .ew_resize => "ew-resize",
+ .ns_resize => "ns-resize",
+ .nesw_resize => "nesw-resize",
+ .nwse_resize => "nwse-resize",
+ .zoom_in => "zoom-in",
+ .zoom_out => "zoom-out",
+ };
+
+ // Set our new cursor.
+ self.as(gtk.Widget).setCursorFromName(name.ptr);
+ }
+
+ fn vadjValueChanged(adj: *gtk.Adjustment, self: *Self) callconv(.c) void {
+ // This will trigger for every single pixel change in the adjustment,
+ // but our core surface handles the noise from this so that identical
+ // rows are cheap.
+ const core_surface = self.core() orelse return;
+ const row: usize = @intFromFloat(@round(adj.getValue()));
+ _ = core_surface.performBindingAction(.{ .scroll_to_row = row }) catch |err| {
+ log.err("error performing scroll_to_row action err={}", .{err});
+ };
+ }
+
+ fn propVAdjustment(
+ self: *Self,
+ _: *gobject.ParamSpec,
+ _: ?*anyopaque,
+ ) callconv(.c) void {
+ const priv = self.private();
+
+ // When vadjustment is first set, we setup the signal group lazily.
+ // This makes it so that if we don't use scrollbars, we never
+ // pay the memory cost of this.
+ const group: *gobject.SignalGroup = priv.vadj_signal_group orelse group: {
+ const group = gobject.SignalGroup.new(gtk.Adjustment.getGObjectType());
+ group.connect(
+ "value-changed",
+ @ptrCast(&vadjValueChanged),
+ self,
+ );
+
+ priv.vadj_signal_group = group;
+ break :group group;
+ };
+
+ // Setup our signal group target
+ group.setTarget(if (priv.vadj) |v| v.as(gobject.Object) else null);
+ }
+
+ /// Handle bell features that need to happen every time a BEL is received
+ /// Currently this is audio and system but this could change in the future.
+ fn ringBell(self: *Self) void {
+ const priv = self.private();
+
+ // Emit the signal
+ signals.bell.impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+
+ // Activate actions if they exist
+ _ = self.as(gtk.Widget).activateAction("tab.ring-bell", null);
+ _ = self.as(gtk.Widget).activateAction("win.ring-bell", null);
+
+ const config = if (priv.config) |c| c.get() else return;
+
+ // Do our sound
+ if (config.@"bell-features".audio) audio: {
+ const config_path = config.@"bell-audio-path" orelse break :audio;
+ const path, const required = switch (config_path) {
+ .optional => |path| .{ path, false },
+ .required => |path| .{ path, true },
+ };
+
+ const volume = std.math.clamp(
+ config.@"bell-audio-volume",
+ 0.0,
+ 1.0,
+ );
+
+ const media_file = media.fromFilename(path) orelse break :audio;
+ media.playMediaFile(media_file, volume, required);
+ }
+ }
+
+ //---------------------------------------------------------------
+ // Gtk.Scrollable interface implementation
+
+ pub fn getHAdjustment(self: *Self) ?*gtk.Adjustment {
+ return self.private().hadj;
+ }
+
+ pub fn setHAdjustment(self: *Self, adj_: ?*gtk.Adjustment) void {
+ self.as(gobject.Object).freezeNotify();
+ defer self.as(gobject.Object).thawNotify();
+ self.as(gobject.Object).notifyByPspec(properties.hadjustment.impl.param_spec);
+
+ const priv = self.private();
+ if (priv.hadj) |old| {
+ old.as(gobject.Object).unref();
+ priv.hadj = null;
+ }
+
+ const adj = adj_ orelse return;
+ _ = adj.as(gobject.Object).ref();
+ priv.hadj = adj;
+ }
+
+ fn getHAdjustmentValue(self: *Self, value: *gobject.Value) void {
+ gobject.ext.Value.set(value, self.getHAdjustment());
+ }
+
+ fn setHAdjustmentValue(self: *Self, value: *const gobject.Value) void {
+ self.setHAdjustment(gobject.ext.Value.get(value, ?*gtk.Adjustment));
+ }
+
+ pub fn getVAdjustment(self: *Self) ?*gtk.Adjustment {
+ return self.private().vadj;
+ }
+
+ pub fn setVAdjustment(self: *Self, adj_: ?*gtk.Adjustment) void {
+ self.as(gobject.Object).freezeNotify();
+ defer self.as(gobject.Object).thawNotify();
+ self.as(gobject.Object).notifyByPspec(properties.vadjustment.impl.param_spec);
+
+ const priv = self.private();
+
+ if (priv.vadj) |old| {
+ old.as(gobject.Object).unref();
+ priv.vadj = null;
+ }
+
+ const adj = adj_ orelse return;
+ _ = adj.as(gobject.Object).ref();
+ priv.vadj = adj;
+ }
+
+ fn getVAdjustmentValue(self: *Self, value: *gobject.Value) void {
+ gobject.ext.Value.set(value, self.getVAdjustment());
+ }
+
+ fn setVAdjustmentValue(self: *Self, value: *const gobject.Value) void {
+ self.setVAdjustment(gobject.ext.Value.get(value, ?*gtk.Adjustment));
+ }
+
+ //---------------------------------------------------------------
+ // Signal Handlers
+
+ pub fn actionPromptTitle(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ const surface = self.core() orelse return;
+ _ = surface.performBindingAction(.prompt_surface_title) catch |err| {
+ log.warn("unable to perform prompt title action err={}", .{err});
+ };
+ }
+
+ pub fn actionNotifyOnNextCommandFinish(
+ action: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ _: *Self,
+ ) callconv(.c) void {
+ const state = action.as(gio.Action).getState() orelse glib.Variant.newBoolean(@intFromBool(false));
+ defer state.unref();
+ const bool_variant_type = glib.ext.VariantType.newFor(bool);
+ defer bool_variant_type.free();
+ if (state.isOfType(bool_variant_type) == 0) return;
+ const value = state.getBoolean() != 0;
+ action.setState(glib.Variant.newBoolean(@intFromBool(!value)));
+ }
+
+ fn childExitedClose(
+ _: *ChildExited,
+ self: *Self,
+ ) callconv(.c) void {
+ // This closes the surface with no confirmation.
+ self.close();
+ }
+
+ fn contextMenuClosed(
+ _: *gtk.PopoverMenu,
+ self: *Self,
+ ) callconv(.c) void {
+ // When the context menu closes, it moves focus back to the tab
+ // bar if there are tabs. That's not correct. We need to grab it
+ // on the surface.
+ self.grabFocus();
+ }
+
+ fn inspectorWeakNotify(
+ ud: ?*anyopaque,
+ _: *gobject.Object,
+ ) callconv(.c) void {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return));
+ const priv = self.private();
+ priv.inspector = null;
+ }
+
+ fn dtDrop(
+ _: *gtk.DropTarget,
+ value: *gobject.Value,
+ _: f64,
+ _: f64,
+ self: *Self,
+ ) callconv(.c) c_int {
+ const alloc = Application.default().allocator();
+
+ if (ext.gValueHolds(value, gdk.FileList.getGObjectType())) {
+ var stream: std.Io.Writer.Allocating = .init(alloc);
+ defer stream.deinit();
+
+ var shell_escape_writer: internal_os.ShellEscapeWriter = .init(&stream.writer);
+ const writer = &shell_escape_writer.writer;
+
+ const list: ?*glib.SList = list: {
+ const unboxed = value.getBoxed() orelse return 0;
+ const fl: *gdk.FileList = @ptrCast(@alignCast(unboxed));
+ break :list fl.getFiles();
+ };
+ defer if (list) |v| v.free();
+
+ {
+ var current: ?*glib.SList = list;
+ while (current) |item| : (current = item.f_next) {
+ const file: *gio.File = @ptrCast(@alignCast(item.f_data orelse continue));
+ const path = file.getPath() orelse continue;
+ const slice = std.mem.span(path);
+ defer glib.free(path);
+
+ writer.writeAll(slice) catch |err| {
+ log.err("unable to write path to buffer: {}", .{err});
+ continue;
+ };
+ writer.writeAll("\n") catch |err| {
+ log.err("unable to write to buffer: {}", .{err});
+ continue;
+ };
+ }
+ }
+
+ const string = stream.toOwnedSliceSentinel(0) catch |err| {
+ log.err("unable to convert to a slice: {}", .{err});
+ return 0;
+ };
+ defer alloc.free(string);
+ Clipboard.paste(self, string);
+ return 1;
+ }
+
+ if (ext.gValueHolds(value, gio.File.getGObjectType())) {
+ const object = value.getObject() orelse return 0;
+ const file = gobject.ext.cast(gio.File, object) orelse return 0;
+ const path = file.getPath() orelse return 0;
+ var stream: std.Io.Writer.Allocating = .init(alloc);
+ defer stream.deinit();
+
+ var shell_escape_writer: internal_os.ShellEscapeWriter = .init(&stream.writer);
+ const writer = &shell_escape_writer.writer;
+ writer.writeAll(std.mem.span(path)) catch |err| {
+ log.err("unable to write path to buffer: {}", .{err});
+ return 0;
+ };
+ writer.writeAll("\n") catch |err| {
+ log.err("unable to write to buffer: {}", .{err});
+ return 0;
+ };
+
+ const string = stream.toOwnedSliceSentinel(0) catch |err| {
+ log.err("unable to convert to a slice: {}", .{err});
+ return 0;
+ };
+ defer alloc.free(string);
+ return 1;
+ }
+
+ if (ext.gValueHolds(value, gobject.ext.types.string)) {
+ if (value.getString()) |string| {
+ Clipboard.paste(self, std.mem.span(string));
+ }
+ return 1;
+ }
+
+ return 1;
+ }
+
+ fn ecKeyPressed(
+ ec_key: *gtk.EventControllerKey,
+ keyval: c_uint,
+ keycode: c_uint,
+ gtk_mods: gdk.ModifierType,
+ self: *Self,
+ ) callconv(.c) c_int {
+ return @intFromBool(self.keyEvent(
+ .press,
+ ec_key,
+ keyval,
+ keycode,
+ gtk_mods,
+ ));
+ }
+
+ fn ecKeyReleased(
+ ec_key: *gtk.EventControllerKey,
+ keyval: c_uint,
+ keycode: c_uint,
+ state: gdk.ModifierType,
+ self: *Self,
+ ) callconv(.c) void {
+ _ = self.keyEvent(
+ .release,
+ ec_key,
+ keyval,
+ keycode,
+ state,
+ );
+ }
+
+ fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void {
+ self.updateFocus(true);
+ }
+
+ fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void {
+ self.updateFocus(false);
+ }
+
+ fn updateFocus(self: *Self, focused: bool) void {
+ const priv = self.private();
+ priv.focused = focused;
+
+ const ctx = priv.im_context.as(gtk.IMContext);
+ if (focused) ctx.focusIn() else ctx.focusOut();
+
+ _ = glib.idleAddOnce(idleFocus, self.ref());
+ self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec);
+
+ // Bell stops ringing as soon as we gain focus
+ if (focused) self.setBellRinging(false);
+ }
+
+ /// The focus callback must be triggered on an idle loop source because
+ /// there are actions within libghostty callbacks (such as showing close
+ /// confirmation dialogs) that can trigger focus loss and cause a deadlock
+ /// because the lock may be held during the callback.
+ ///
+ /// Userdata should be a `*Surface`. This will unref once.
+ fn idleFocus(ud: ?*anyopaque) callconv(.c) void {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return));
+ defer self.unref();
+
+ const priv = self.private();
+ const surface = priv.core_surface orelse return;
+ surface.focusCallback(priv.focused) catch |err| {
+ log.warn("error in focus callback err={}", .{err});
+ };
+ }
+
+ fn gcMouseDown(
+ gesture: *gtk.GestureClick,
+ _: c_int,
+ x: f64,
+ y: f64,
+ self: *Self,
+ ) callconv(.c) void {
+ const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return;
+
+ // Bell stops ringing if any mouse button is pressed.
+ self.setBellRinging(false);
+
+ // Get our surface. If we don't have one, ignore this.
+ const priv = self.private();
+ const core_surface = priv.core_surface orelse return;
+
+ // If we don't have focus, grab it.
+ const gl_area_widget = priv.gl_area.as(gtk.Widget);
+ const had_focus = gl_area_widget.hasFocus() != 0;
+ if (!had_focus) {
+ _ = gl_area_widget.grabFocus();
+ }
+
+ // Report the event
+ const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton());
+
+ // If this click is only transitioning split focus, suppress it so
+ // it doesn't get forwarded to the terminal as a mouse event.
+ if (!had_focus and button == .left) {
+ priv.suppress_left_mouse_release = true;
+ return;
+ }
+
+ if (button == .middle and !priv.gtk_enable_primary_paste) {
+ return;
+ }
+
+ const consumed = consumed: {
+ const gtk_mods = event.getModifierState();
+ const mods = gtk_key.translateMods(gtk_mods);
+ break :consumed core_surface.mouseButtonCallback(
+ .press,
+ button,
+ mods,
+ ) catch |err| err: {
+ log.warn("error in key callback err={}", .{err});
+ break :err false;
+ };
+ };
+
+ // If a right click isn't consumed, mouseButtonCallback selects the hovered
+ // word and returns false. We can use this to handle the context menu
+ // opening under normal scenarios.
+ if (!consumed and button == .right) {
+ signals.menu.impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+
+ const rect: gdk.Rectangle = .{
+ .f_x = @intFromFloat(x),
+ .f_y = @intFromFloat(y),
+ .f_width = 1,
+ .f_height = 1,
+ };
+
+ const popover = priv.context_menu.as(gtk.Popover);
+ popover.setPointingTo(&rect);
+ popover.popup();
+ }
+ }
+
+ fn gcMouseUp(
+ gesture: *gtk.GestureClick,
+ _: c_int,
+ _: f64,
+ _: f64,
+ self: *Self,
+ ) callconv(.c) void {
+ const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return;
+
+ const priv = self.private();
+ const surface = priv.core_surface orelse return;
+ const gtk_mods = event.getModifierState();
+ const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton());
+
+ if (button == .left and priv.suppress_left_mouse_release) {
+ priv.suppress_left_mouse_release = false;
+ return;
+ }
+
+ if (button == .middle and !priv.gtk_enable_primary_paste) {
+ return;
+ }
+
+ const mods = gtk_key.translateMods(gtk_mods);
+ const consumed = surface.mouseButtonCallback(
+ .release,
+ button,
+ mods,
+ ) catch |err| {
+ log.warn("error in key callback err={}", .{err});
+ return;
+ };
+
+ // Trigger the on-screen keyboard if we have no selection,
+ // and that the mouse event hasn't been intercepted by the callback.
+ //
+ // It's better to do this here rather than within the core callback
+ // since we have direct access to the underlying gdk.Event here.
+ if (!consumed and button == .left and !surface.hasSelection()) {
+ if (!self.showOnScreenKeyboard(event)) {
+ log.warn("failed to activate the on-screen keyboard", .{});
+ }
+ }
+ }
+
+ fn ecMouseMotion(
+ ec: *gtk.EventControllerMotion,
+ x: f64,
+ y: f64,
+ self: *Self,
+ ) callconv(.c) void {
+ const event = ec.as(gtk.EventController).getCurrentEvent() orelse return;
+ const priv = self.private();
+
+ const scaled = self.scaledCoordinates(x, y);
+ const pos: apprt.CursorPos = .{
+ .x = @floatCast(scaled.x),
+ .y = @floatCast(scaled.y),
+ };
+
+ // There seem to be at least two cases where GTK issues a mouse motion
+ // event without the cursor actually moving:
+ // 1. GLArea is resized under the mouse. This has the unfortunate
+ // side effect of causing focus to potentially change when
+ // `focus-follows-mouse` is enabled.
+ // 2. The window title is updated. This can cause the mouse to unhide
+ // incorrectly when hide-mouse-when-typing is enabled.
+ // To prevent incorrect behavior, we'll only grab focus and
+ // continue with callback logic if the cursor has actually moved.
+ const is_cursor_still = @abs(priv.cursor_pos.x - pos.x) < 1 and
+ @abs(priv.cursor_pos.y - pos.y) < 1;
+ if (is_cursor_still) return;
+
+ // If we don't have focus, and we want it, grab it.
+ if (priv.config) |config| {
+ const gl_area_widget = priv.gl_area.as(gtk.Widget);
+ if (gl_area_widget.hasFocus() == 0 and
+ config.get().@"focus-follows-mouse")
+ {
+ _ = gl_area_widget.grabFocus();
+ }
+ }
+
+ // Our pos changed, update
+ priv.cursor_pos = pos;
+
+ // Notify the callback
+ if (priv.core_surface) |surface| {
+ const gtk_mods = event.getModifierState();
+ const mods = gtk_key.translateMods(gtk_mods);
+ surface.cursorPosCallback(priv.cursor_pos, mods) catch |err| {
+ log.warn("error in cursor pos callback err={}", .{err});
+ };
+ }
+ }
+
+ fn ecMouseLeave(
+ ec_motion: *gtk.EventControllerMotion,
+ self: *Self,
+ ) callconv(.c) void {
+ const event = ec_motion.as(gtk.EventController).getCurrentEvent() orelse return;
+
+ // Get our modifiers
+ const priv = self.private();
+ if (priv.core_surface) |surface| {
+ // If we have a core surface then we can send the cursor pos
+ // callback with an invalid position to indicate the mouse left.
+ const gtk_mods = event.getModifierState();
+ const mods = gtk_key.translateMods(gtk_mods);
+ surface.cursorPosCallback(
+ .{ .x = -1, .y = -1 },
+ mods,
+ ) catch |err| {
+ log.warn("error in cursor pos callback err={}", .{err});
+ return;
+ };
+ }
+ }
+
+ fn ecMouseScrollVerticalPrecisionBegin(
+ _: *gtk.EventControllerScroll,
+ self: *Self,
+ ) callconv(.c) void {
+ self.private().precision_scroll = true;
+ }
+
+ fn ecMouseScrollVerticalPrecisionEnd(
+ _: *gtk.EventControllerScroll,
+ self: *Self,
+ ) callconv(.c) void {
+ self.private().precision_scroll = false;
+ }
+
+ fn ecMouseScrollVertical(
+ _: *gtk.EventControllerScroll,
+ x: f64,
+ y: f64,
+ self: *Self,
+ ) callconv(.c) c_int {
+ const priv: *Private = self.private();
+ const surface = priv.core_surface orelse return 0;
+
+ // Multiply precision scrolls by 10 to get a better response from
+ // touchpad scrolling
+ const multiplier: f64 = if (priv.precision_scroll) 10.0 else 1.0;
+ const scroll_mods: input.ScrollMods = .{
+ .precision = priv.precision_scroll,
+ };
+
+ const scaled = self.scaledCoordinates(x, y);
+ surface.scrollCallback(
+ // We invert because we apply natural scrolling to the values.
+ // This behavior has existed for years without Linux users complaining
+ // but I suspect we'll have to make this configurable in the future
+ // or read a system setting.
+ scaled.x * -1 * multiplier,
+ scaled.y * -1 * multiplier,
+ scroll_mods,
+ ) catch |err| {
+ log.warn("error in scroll callback err={}", .{err});
+ return 0;
+ };
+
+ return 1;
+ }
+
+ fn ecMouseScrollHorizontal(
+ ec: *gtk.EventControllerScroll,
+ x: f64,
+ _: f64,
+ self: *Self,
+ ) callconv(.c) c_int {
+ const priv: *Private = self.private();
+
+ switch (ec.getUnit()) {
+ .surface => {},
+ .wheel => return @intFromBool(false),
+ else => return @intFromBool(false),
+ }
+
+ priv.pending_horizontal_scroll += x;
+
+ if (@abs(priv.pending_horizontal_scroll) < 120) {
+ if (priv.pending_horizontal_scroll_reset) |v| {
+ _ = glib.Source.remove(v);
+ priv.pending_horizontal_scroll_reset = null;
+ }
+ priv.pending_horizontal_scroll_reset = glib.timeoutAdd(500, ecMouseScrollHorizontalReset, self);
+ return @intFromBool(true);
+ }
+
+ _ = self.as(gtk.Widget).activateAction(
+ if (priv.pending_horizontal_scroll < 0.0)
+ "tab.next-page"
+ else
+ "tab.previous-page",
+ null,
+ );
+
+ if (priv.pending_horizontal_scroll_reset) |v| {
+ _ = glib.Source.remove(v);
+ priv.pending_horizontal_scroll_reset = null;
+ }
+
+ priv.pending_horizontal_scroll = 0.0;
+
+ return @intFromBool(true);
+ }
+
+ fn ecMouseScrollHorizontalReset(ud: ?*anyopaque) callconv(.c) c_int {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return @intFromBool(glib.SOURCE_REMOVE)));
+ const priv: *Private = self.private();
+ priv.pending_horizontal_scroll = 0.0;
+ priv.pending_horizontal_scroll_reset = null;
+ return @intFromBool(glib.SOURCE_REMOVE);
+ }
+
+ fn imPreeditStart(
+ _: *gtk.IMMulticontext,
+ self: *Self,
+ ) callconv(.c) void {
+ // log.warn("GTKIM: preedit start", .{});
+
+ // Start our composing state for the input method and reset our
+ // input buffer to empty.
+ const priv = self.private();
+ priv.im_composing = true;
+ priv.im_len = 0;
+ }
+
+ fn imPreeditChanged(
+ ctx: *gtk.IMMulticontext,
+ self: *Self,
+ ) callconv(.c) void {
+ const priv = self.private();
+
+ // Any preedit change should mark that we're composing. Its possible this
+ // is false using fcitx5-hangul and typing "dkssud<space>" ("안녕"). The
+ // second "s" results in a "commit" for "안" which sets composing to false,
+ // but then immediately sends a preedit change for the next symbol. With
+ // composing set to false we won't commit this text. Therefore, we must
+ // ensure it is set here.
+ priv.im_composing = true;
+
+ // We can't set our preedit on our surface unless we're realized.
+ // We do this now because we want to still keep our input method
+ // state coherent.
+ const surface = priv.core_surface orelse return;
+
+ // Get our pre-edit string that we'll use to show the user.
+ var buf: [*:0]u8 = undefined;
+ ctx.as(gtk.IMContext).getPreeditString(
+ &buf,
+ null,
+ null,
+ );
+ defer glib.free(buf);
+ const str = std.mem.sliceTo(buf, 0);
+
+ // Update our preedit state in Ghostty core
+ // log.warn("GTKIM: preedit change str={s}", .{str});
+ surface.preeditCallback(str) catch |err| {
+ log.warn(
+ "error in preedit callback err={}",
+ .{err},
+ );
+ };
+ }
+
+ fn imPreeditEnd(
+ _: *gtk.IMMulticontext,
+ self: *Self,
+ ) callconv(.c) void {
+ // log.warn("GTKIM: preedit end", .{});
+
+ // End our composing state for GTK, allowing us to commit the text.
+ const priv = self.private();
+ priv.im_composing = false;
+
+ // End our preedit state in Ghostty core
+ const surface = priv.core_surface orelse return;
+ surface.preeditCallback(null) catch |err| {
+ log.warn("error in preedit callback err={}", .{err});
+ };
+ }
+
+ fn imCommit(
+ _: *gtk.IMMulticontext,
+ bytes: [*:0]u8,
+ self: *Self,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const str = std.mem.sliceTo(bytes, 0);
+
+ // log.debug("GTKIM: input commit composing={} keyevent={} str={s}", .{
+ // self.im_composing,
+ // self.in_keyevent,
+ // str,
+ // });
+
+ // We need to handle commit specially if we're in a key event.
+ // Specifically, GTK will send us a commit event for basic key
+ // encodings like "a" (on a US layout keyboard). We don't want
+ // to treat this as IME committed text because we want to associate
+ // it with a key event (i.e. "a" key press).
+ switch (priv.in_keyevent) {
+ // If we're not in a key event then this commit is from
+ // some other source (i.e. on-screen keyboard, tablet, etc.)
+ // and we want to commit the text to the core surface.
+ .false => {},
+
+ // If we're in a composing state and in a key event then this
+ // key event is resulting in a commit of multiple keypresses
+ // and we don't want to encode it alongside the keypress.
+ .composing => {},
+
+ // If we're not composing then this commit is just a normal
+ // key encoding and we want our key event to handle it so
+ // that Ghostty can be aware of the key event alongside
+ // the text.
+ .not_composing => {
+ if (str.len > priv.im_buf.len) {
+ log.warn("not enough buffer space for input method commit", .{});
+ return;
+ }
+
+ // Copy our committed text to the buffer
+ @memcpy(priv.im_buf[0..str.len], str);
+ priv.im_len = @intCast(str.len);
+
+ // log.debug("input commit len={}", .{priv.im_len});
+ return;
+ },
+ }
+
+ // If we reach this point from above it means we're composing OR
+ // not in a keypress. In either case, we want to commit the text
+ // given to us because that's what GTK is asking us to do. If we're
+ // not in a keypress it means that this commit came via a non-keyboard
+ // event (i.e. on-screen keyboard, tablet of some kind, etc.).
+
+ // Committing ends composing state
+ priv.im_composing = false;
+
+ // We can't set our preedit on our surface unless we're realized.
+ // We do this now because we want to still keep our input method
+ // state coherent.
+ if (priv.core_surface) |surface| {
+ // End our preedit state. Well-behaved input methods do this for us
+ // by triggering a preedit-end event but some do not (ibus 1.5.29).
+ surface.preeditCallback(null) catch |err| {
+ log.warn("error in preedit callback err={}", .{err});
+ };
+
+ // Send the text to the core surface, associated with no key (an
+ // invalid key, which should produce no PTY encoding).
+ _ = surface.keyCallback(.{
+ .action = .press,
+ .key = .unidentified,
+ .mods = .{},
+ .consumed_mods = .{},
+ .composing = false,
+ .utf8 = str,
+ }) catch |err| {
+ log.warn("error in key callback err={}", .{err});
+ };
+ }
+ }
+
+ fn glareaRealize(
+ _: *gtk.GLArea,
+ self: *Self,
+ ) callconv(.c) void {
+ log.debug("realize", .{});
+
+ // Make the GL area current so we can detect any OpenGL errors. If
+ // we have errors here we can't render and we switch to the error
+ // state.
+ const priv = self.private();
+ priv.gl_area.makeCurrent();
+ if (priv.gl_area.getError()) |err| {
+ log.warn("failed to make GL context current: {s}", .{err.f_message orelse "(no message)"});
+ log.warn("this error is almost always due to a library, driver, or GTK issue", .{});
+ log.warn("this is a common cause of this issue: https://ghostty.org/docs/help/gtk-opengl-context", .{});
+ self.setError(true);
+ return;
+ }
+
+ // If we already have an initialized surface then we notify it.
+ // If we don't, we'll initialize it on the first resize so we have
+ // our proper initial dimensions.
+ if (priv.core_surface) |v| realize: {
+ v.renderer.displayRealized() catch |err| {
+ log.warn("core displayRealized failed err={}", .{err});
+ break :realize;
+ };
+
+ self.redraw();
+ }
+
+ // Setup our input method. We do this here because this will
+ // create a strong reference back to ourself and we want to be
+ // able to release that in unrealize.
+ priv.im_context.as(gtk.IMContext).setClientWidget(self.as(gtk.Widget));
+ }
+
+ fn glareaUnrealize(
+ gl_area: *gtk.GLArea,
+ self: *Self,
+ ) callconv(.c) void {
+ log.debug("unrealize", .{});
+
+ // Notify our core surface
+ const priv = self.private();
+ if (priv.core_surface) |surface| {
+ // There is no guarantee that our GLArea context is current
+ // when unrealize is emitted, so we need to make it current.
+ gl_area.makeCurrent();
+ if (gl_area.getError()) |err| {
+ // I don't know a scenario this can happen, but it means
+ // we probably leaked memory because displayUnrealized
+ // below frees resources that aren't specifically OpenGL
+ // related. I didn't make the OpenGL renderer handle this
+ // scenario because I don't know if its even possible
+ // under valid circumstances, so let's log.
+ log.warn(
+ "gl_area_make_current failed in unrealize msg={s}",
+ .{err.f_message orelse "(no message)"},
+ );
+ log.warn("OpenGL resources and memory likely leaked", .{});
+ return;
+ }
+
+ surface.renderer.displayUnrealized();
+ }
+
+ // Unset our input method
+ priv.im_context.as(gtk.IMContext).setClientWidget(null);
+ }
+
+ fn glareaRender(
+ _: *gtk.GLArea,
+ _: *gdk.GLContext,
+ self: *Self,
+ ) callconv(.c) c_int {
+ // If we don't have a surface then we failed to initialize for
+ // some reason and there's nothing to draw to the GLArea.
+ const priv = self.private();
+ const surface = priv.core_surface orelse return 1;
+
+ surface.renderer.drawFrame(true) catch |err| {
+ log.warn("failed to draw frame err={}", .{err});
+ return 0;
+ };
+
+ return 1;
+ }
+
+ fn glareaResize(
+ gl_area: *gtk.GLArea,
+ width: c_int,
+ height: c_int,
+ self: *Self,
+ ) callconv(.c) void {
+ // Some debug output to help understand what GTK is telling us.
+ {
+ const widget = gl_area.as(gtk.Widget);
+ const scale_factor = widget.getScaleFactor();
+ const window_scale_factor = scale: {
+ const root = widget.getRoot() orelse break :scale 0;
+ const gtk_native = root.as(gtk.Native);
+ const gdk_surface = gtk_native.getSurface() orelse break :scale 0;
+ break :scale gdk_surface.getScaleFactor();
+ };
+
+ log.debug("gl resize width={} height={} scale={} window_scale={}", .{
+ width,
+ height,
+ scale_factor,
+ window_scale_factor,
+ });
+ }
+
+ // Store our cached size
+ const priv = self.private();
+
+ const new_size: apprt.SurfaceSize = .{
+ .width = @intCast(width),
+ .height = @intCast(height),
+ };
+ const changed = !priv.size.eql(&new_size);
+ priv.size = new_size;
+
+ // If our surface is realize, we send callbacks.
+ if (priv.core_surface) |surface| {
+ // We also update the content scale because there is no signal for
+ // content scale change and it seems to trigger a resize event.
+ surface.contentScaleCallback(self.getContentScale()) catch |err| {
+ log.warn("error in content scale callback err={}", .{err});
+ };
+
+ if (changed) {
+ surface.sizeCallback(new_size) catch |err| {
+ log.warn("error in size callback err={}", .{err});
+ };
+ // Setup our resize overlay if configured
+ self.resizeOverlaySchedule();
+ }
+
+ return;
+ }
+
+ // If we don't have a surface, then we initialize it.
+ self.initSurface() catch |err| {
+ log.warn("surface failed to initialize err={}", .{err});
+ };
+ }
+
+ const InitError = Allocator.Error || error{
+ GLAreaError,
+ SurfaceError,
+ };
+
+ fn initSurface(self: *Self) InitError!void {
+ const priv: *Private = self.private();
+ assert(priv.core_surface == null);
+ const gl_area = priv.gl_area;
+
+ // We need to make the context current so we can call GL functions.
+ // This is required for all surface operations.
+ gl_area.makeCurrent();
+ if (gl_area.getError()) |err| {
+ log.warn("failed to make GL context current: {s}", .{err.f_message orelse "(no message)"});
+ log.warn("this error is usually due to a driver or gtk bug", .{});
+ log.warn("this is a common cause of this issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/4950", .{});
+ return error.GLAreaError;
+ }
+
+ const app = Application.default();
+ const alloc = app.allocator();
+
+ // Make our pointer to store our surface
+ const surface = try alloc.create(CoreSurface);
+ errdefer alloc.destroy(surface);
+
+ // Add ourselves to the list of surfaces on the app.
+ try app.core().addSurface(self.rt());
+ errdefer app.core().deleteSurface(self.rt());
+
+ // Initialize our surface configuration.
+ var config = try apprt.surface.newConfig(
+ app.core(),
+ priv.config.?.get(),
+ priv.context,
+ );
+ defer config.deinit();
+
+ if (priv.overrides.command) |c| {
+ config.command = try c.clone(config._arena.?.allocator());
+ }
+ if (priv.overrides.working_directory) |wd| {
+ const config_alloc = config.arenaAlloc();
+ var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, wd) };
+ try wd_val.finalize(config_alloc);
+ config.@"working-directory" = wd_val;
+ }
+
+ // Properties that can impact surface init
+ if (priv.font_size_request) |size| config.@"font-size" = size.points;
+ if (priv.pwd) |pwd| {
+ const config_alloc = config.arenaAlloc();
+ var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, pwd) };
+ try wd_val.finalize(config_alloc);
+ config.@"working-directory" = wd_val;
+ }
+
+ // Initialize the surface
+ surface.init(
+ alloc,
+ &config,
+ app.core(),
+ app.rt(),
+ &priv.rt_surface,
+ ) catch |err| {
+ log.warn("failed to initialize surface err={}", .{err});
+ return error.SurfaceError;
+ };
+ errdefer surface.deinit();
+
+ // Store it!
+ priv.core_surface = surface;
+
+ // Emit the signal that we initialized the surface.
+ Surface.signals.init.impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+
+ self.updateFocus(priv.focused);
+ }
+
+ fn resizeOverlaySchedule(self: *Self) void {
+ const priv = self.private();
+ const surface = priv.core_surface orelse return;
+
+ // Only show the resize overlay if its enabled
+ const config = if (priv.config) |c| c.get() else return;
+ switch (config.@"resize-overlay") {
+ .always, .@"after-first" => {},
+ .never => return,
+ }
+
+ // If we have resize overlays enabled, setup an idler
+ // to show that. We do this in an idle tick because doing it
+ // during the resize results in flickering.
+ var buf: [32]u8 = undefined;
+ priv.resize_overlay.setLabel(text: {
+ const grid_size = surface.size.grid();
+ break :text std.fmt.bufPrintZ(
+ &buf,
+ "{d} x {d}",
+ .{
+ grid_size.columns,
+ grid_size.rows,
+ },
+ ) catch |err| err: {
+ log.warn("unable to format text: {}", .{err});
+ break :err "";
+ };
+ });
+ priv.resize_overlay.schedule();
+ }
+
+ fn ecUrlMouseEnter(
+ _: *gtk.EventControllerMotion,
+ _: f64,
+ _: f64,
+ self: *Self,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const right = priv.url_right.as(gtk.Widget);
+ right.setVisible(1);
+ }
+
+ fn ecUrlMouseLeave(
+ _: *gtk.EventControllerMotion,
+ self: *Self,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const right = priv.url_right.as(gtk.Widget);
+ right.setVisible(0);
+ }
+
+ fn titleDialogSet(
+ _: *TitleDialog,
+ title_ptr: [*:0]const u8,
+ self: *Self,
+ ) callconv(.c) void {
+ const title = std.mem.span(title_ptr);
+ self.setTitleOverride(if (title.len == 0) null else title);
+ }
+
+ fn searchStop(_: *SearchOverlay, self: *Self) callconv(.c) void {
+ const surface = self.core() orelse return;
+ _ = surface.performBindingAction(.end_search) catch |err| {
+ log.warn("unable to perform end_search action err={}", .{err});
+ };
+ _ = self.private().gl_area.as(gtk.Widget).grabFocus();
+ }
+
+ fn searchChanged(_: *SearchOverlay, needle: ?[*:0]const u8, self: *Self) callconv(.c) void {
+ const surface = self.core() orelse return;
+ _ = surface.performBindingAction(.{ .search = std.mem.sliceTo(needle orelse "", 0) }) catch |err| {
+ log.warn("unable to perform search action err={}", .{err});
+ };
+ }
+
+ fn searchNextMatch(_: *SearchOverlay, self: *Self) callconv(.c) void {
+ const surface = self.core() orelse return;
+ _ = surface.performBindingAction(.{ .navigate_search = .next }) catch |err| {
+ log.warn("unable to perform navigate_search action err={}", .{err});
+ };
+ }
+
+ fn searchPreviousMatch(_: *SearchOverlay, self: *Self) callconv(.c) void {
+ const surface = self.core() orelse return;
+ _ = surface.performBindingAction(.{ .navigate_search = .previous }) catch |err| {
+ log.warn("unable to perform navigate_search action err={}", .{err});
+ };
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const refSink = C.refSink;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gobject.ext.ensureType(ResizeOverlay);
+ gobject.ext.ensureType(SearchOverlay);
+ gobject.ext.ensureType(KeyStateOverlay);
+ gobject.ext.ensureType(ChildExited);
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 2,
+ .name = "surface",
+ }),
+ );
+
+ // Bindings
+ class.bindTemplateChildPrivate("gl_area", .{});
+ class.bindTemplateChildPrivate("url_left", .{});
+ class.bindTemplateChildPrivate("url_right", .{});
+ class.bindTemplateChildPrivate("child_exited_overlay", .{});
+ class.bindTemplateChildPrivate("context_menu", .{});
+ class.bindTemplateChildPrivate("error_page", .{});
+ class.bindTemplateChildPrivate("progress_bar_overlay", .{});
+ class.bindTemplateChildPrivate("resize_overlay", .{});
+ class.bindTemplateChildPrivate("search_overlay", .{});
+ class.bindTemplateChildPrivate("key_state_overlay", .{});
+ class.bindTemplateChildPrivate("terminal_page", .{});
+ class.bindTemplateChildPrivate("drop_target", .{});
+ class.bindTemplateChildPrivate("im_context", .{});
+
+ // Template Callbacks
+ class.bindTemplateCallback("focus_enter", &ecFocusEnter);
+ class.bindTemplateCallback("focus_leave", &ecFocusLeave);
+ class.bindTemplateCallback("key_pressed", &ecKeyPressed);
+ class.bindTemplateCallback("key_released", &ecKeyReleased);
+ class.bindTemplateCallback("mouse_down", &gcMouseDown);
+ class.bindTemplateCallback("mouse_up", &gcMouseUp);
+ class.bindTemplateCallback("mouse_motion", &ecMouseMotion);
+ class.bindTemplateCallback("mouse_leave", &ecMouseLeave);
+ class.bindTemplateCallback("scroll_vertical", &ecMouseScrollVertical);
+ class.bindTemplateCallback("scroll_vertical_begin", &ecMouseScrollVerticalPrecisionBegin);
+ class.bindTemplateCallback("scroll_vertical_end", &ecMouseScrollVerticalPrecisionEnd);
+ class.bindTemplateCallback("scroll_horizontal", &ecMouseScrollHorizontal);
+ class.bindTemplateCallback("drop", &dtDrop);
+ class.bindTemplateCallback("gl_realize", &glareaRealize);
+ class.bindTemplateCallback("gl_unrealize", &glareaUnrealize);
+ class.bindTemplateCallback("gl_render", &glareaRender);
+ class.bindTemplateCallback("gl_resize", &glareaResize);
+ class.bindTemplateCallback("im_preedit_start", &imPreeditStart);
+ class.bindTemplateCallback("im_preedit_changed", &imPreeditChanged);
+ class.bindTemplateCallback("im_preedit_end", &imPreeditEnd);
+ class.bindTemplateCallback("im_commit", &imCommit);
+ class.bindTemplateCallback("url_mouse_enter", &ecUrlMouseEnter);
+ class.bindTemplateCallback("url_mouse_leave", &ecUrlMouseLeave);
+ class.bindTemplateCallback("child_exited_close", &childExitedClose);
+ class.bindTemplateCallback("context_menu_closed", &contextMenuClosed);
+ class.bindTemplateCallback("notify_config", &propConfig);
+ class.bindTemplateCallback("notify_error", &propError);
+ class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl);
+ class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden);
+ class.bindTemplateCallback("notify_mouse_shape", &propMouseShape);
+ class.bindTemplateCallback("notify_vadjustment", &propVAdjustment);
+ class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown);
+ class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown);
+ class.bindTemplateCallback("search_stop", &searchStop);
+ class.bindTemplateCallback("search_changed", &searchChanged);
+ class.bindTemplateCallback("search_next_match", &searchNextMatch);
+ class.bindTemplateCallback("search_previous_match", &searchPreviousMatch);
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.@"bell-ringing".impl,
+ properties.config.impl,
+ properties.@"child-exited".impl,
+ properties.@"default-size".impl,
+ properties.@"error".impl,
+ properties.@"font-size-request".impl,
+ properties.focused.impl,
+ properties.@"key-sequence".impl,
+ properties.@"key-table".impl,
+ properties.@"min-size".impl,
+ properties.@"mouse-shape".impl,
+ properties.@"mouse-hidden".impl,
+ properties.@"mouse-hover-url".impl,
+ properties.pwd.impl,
+ properties.title.impl,
+ properties.@"title-override".impl,
+ properties.zoom.impl,
+ properties.@"is-split".impl,
+ properties.readonly.impl,
+
+ // For Gtk.Scrollable
+ properties.hadjustment.impl,
+ properties.vadjustment.impl,
+ properties.@"hscroll-policy".impl,
+ properties.@"vscroll-policy".impl,
+ });
+
+ // Signals
+ signals.bell.impl.register(.{});
+ signals.@"close-request".impl.register(.{});
+ signals.@"clipboard-read".impl.register(.{});
+ signals.@"clipboard-write".impl.register(.{});
+ signals.init.impl.register(.{});
+ signals.menu.impl.register(.{});
+ signals.@"present-request".impl.register(.{});
+ signals.@"toggle-fullscreen".impl.register(.{});
+ signals.@"toggle-maximize".impl.register(.{});
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+
+ /// Simple dimensions struct for the surface used by various properties.
+ pub const Size = extern struct {
+ width: u32,
+ height: u32,
+
+ pub const getGObjectType = gobject.ext.defineBoxed(
+ Size,
+ .{ .name = "GhosttySurfaceSize" },
+ );
+ };
+};
+
+/// The state of the key event while we're doing IM composition.
+/// See gtkKeyPressed for detailed descriptions.
+pub const IMKeyEvent = enum {
+ /// Not in a key event.
+ false,
+
+ /// In a key event but im_composing was either true or false
+ /// prior to the calling IME processing. This is important to
+ /// work around different input methods calling commit and
+ /// preedit end in a different order.
+ composing,
+ not_composing,
+};
+
+fn translateMouseButton(button: c_uint) input.MouseButton {
+ return switch (button) {
+ 1 => .left,
+ 2 => .middle,
+ 3 => .right,
+ 4 => .four,
+ 5 => .five,
+ 6 => .six,
+ 7 => .seven,
+ 8 => .eight,
+ 9 => .nine,
+ 10 => .ten,
+ 11 => .eleven,
+ else => .unknown,
+ };
+}
+
+/// A namespace for our clipboard-related functions so Surface isn't SO large.
+const Clipboard = struct {
+ /// Set the clipboard contents.
+ pub fn set(
+ self: *Surface,
+ clipboard_type: apprt.Clipboard,
+ contents: []const apprt.ClipboardContent,
+ confirm: bool,
+ ) void {
+ const priv = self.private();
+
+ // Grab our plaintext content for use in confirmation dialogs
+ // and signals. We always expect one to exist.
+ const text: [:0]const u8 = for (contents) |content| {
+ if (std.mem.eql(u8, content.mime, "text/plain")) {
+ break content.data;
+ }
+ } else return;
+
+ // If no confirmation is necessary, set the clipboard.
+ if (!confirm) {
+ const clipboard = get(
+ priv.gl_area.as(gtk.Widget),
+ clipboard_type,
+ ) orelse return;
+
+ const alloc = Application.default().allocator();
+ if (alloc.alloc(*gdk.ContentProvider, contents.len)) |providers| {
+ // Note: we don't need to unref the individual providers
+ // because new_union takes ownership of them.
+ defer alloc.free(providers);
+
+ for (contents, 0..) |content, i| {
+ const bytes = glib.Bytes.new(content.data.ptr, content.data.len);
+ defer bytes.unref();
+ if (std.mem.eql(u8, content.mime, "text/plain")) {
+ // Add an explicit UTF-8 encoding parameter to the
+ // text/plain type. The default charset when there is
+ // none is ASCII, and lots of things look for UTF-8
+ // specifically.
+ // The specs are not clear about the order here, but
+ // some clients apparently pick the first match in the
+ // order we set here then garble up bare 'text/plain'
+ // with non-ASCII UTF-8 content, so offer UTF-8 first.
+ //
+ // Note that under X11, GTK automatically adds the
+ // UTF8_STRING atom when this is present.
+ const text_provider_atoms = [_][:0]const u8{
+ "text/plain;charset=utf-8",
+ "text/plain",
+ };
+ var text_providers: [text_provider_atoms.len]*gdk.ContentProvider = undefined;
+ for (text_provider_atoms, 0..) |atom, j| {
+ const provider = gdk.ContentProvider.newForBytes(atom, bytes);
+ text_providers[j] = provider;
+ }
+ const text_union = gdk.ContentProvider.newUnion(
+ &text_providers,
+ text_providers.len,
+ );
+ providers[i] = text_union;
+ } else {
+ const provider = gdk.ContentProvider.newForBytes(content.mime, bytes);
+ providers[i] = provider;
+ }
+ }
+
+ const all = gdk.ContentProvider.newUnion(providers.ptr, providers.len);
+ defer all.unref();
+ _ = clipboard.setContent(all);
+ } else |_| {
+ // If we fail to alloc, we can at least set the text content.
+ clipboard.setText(text);
+ }
+
+ Surface.signals.@"clipboard-write".impl.emit(
+ self,
+ null,
+ .{ clipboard_type, text.ptr },
+ null,
+ );
+
+ return;
+ }
+
+ showClipboardConfirmation(
+ self,
+ .{ .osc_52_write = clipboard_type },
+ text,
+ );
+ }
+
+ /// Request data from the clipboard (read the clipboard). This
+ /// completes asynchronously and will call the `completeClipboardRequest`
+ /// core surface API when done.
+ ///
+ /// Returns true if the request was started, false if the clipboard
+ /// doesn't contain text (allowing performable keybinds to pass through).
+ pub fn request(
+ self: *Surface,
+ clipboard_type: apprt.Clipboard,
+ state: apprt.ClipboardRequest,
+ ) Allocator.Error!bool {
+ // Get our requested clipboard
+ const clipboard = get(
+ self.private().gl_area.as(gtk.Widget),
+ clipboard_type,
+ ) orelse return false;
+
+ // For paste requests, check if clipboard has text format available.
+ // This is a synchronous check that allows performable keybinds to
+ // pass through when the clipboard contains non-text content (e.g., images).
+ if (state == .paste) {
+ const formats = clipboard.getFormats();
+ if (formats.containGtype(gobject.ext.types.string) == 0) {
+ log.debug("clipboard has no text format, not starting paste request", .{});
+ return false;
+ }
+ }
+
+ // Allocate our userdata
+ const alloc = Application.default().allocator();
+ const ud = try alloc.create(Request);
+ errdefer alloc.destroy(ud);
+ ud.* = .{
+ // Important: we ref self here so that we can't free memory
+ // while we have an outstanding clipboard read.
+ .self = self.ref(),
+ .state = state,
+ };
+ errdefer self.unref();
+
+ // Read
+ clipboard.readTextAsync(
+ null,
+ clipboardReadText,
+ ud,
+ );
+
+ return true;
+ }
+
+ /// Paste explicit text directly into the surface, regardless of the
+ /// actual clipboard contents.
+ pub fn paste(
+ self: *Surface,
+ text: [:0]const u8,
+ ) void {
+ if (text.len == 0) return;
+
+ const surface = self.private().core_surface orelse return;
+ surface.completeClipboardRequest(
+ .paste,
+ text,
+ false,
+ ) catch |err| switch (err) {
+ error.UnsafePaste,
+ error.UnauthorizedPaste,
+ => {
+ showClipboardConfirmation(
+ self,
+ .paste,
+ text,
+ );
+ return;
+ },
+
+ else => {
+ log.warn(
+ "failed to complete clipboard request err={}",
+ .{err},
+ );
+ return;
+ },
+ };
+ }
+
+ /// Get the specific type of clipboard for a widget.
+ fn get(
+ widget: *gtk.Widget,
+ clipboard: apprt.Clipboard,
+ ) ?*gdk.Clipboard {
+ return switch (clipboard) {
+ .standard => widget.getClipboard(),
+ .selection, .primary => widget.getPrimaryClipboard(),
+ };
+ }
+
+ fn showClipboardConfirmation(
+ self: *Surface,
+ req: apprt.ClipboardRequest,
+ str: [:0]const u8,
+ ) void {
+ // Build a text buffer for our contents
+ const contents_buf: *gtk.TextBuffer = .new(null);
+ defer contents_buf.unref();
+ contents_buf.insertAtCursor(str, @intCast(str.len));
+
+ // Confirm
+ const dialog = gobject.ext.newInstance(
+ ClipboardConfirmationDialog,
+ .{
+ .request = &req,
+ .@"can-remember" = switch (req) {
+ .osc_52_read, .osc_52_write => true,
+ .paste => false,
+ },
+ .@"clipboard-contents" = contents_buf,
+ },
+ );
+
+ _ = ClipboardConfirmationDialog.signals.confirm.connect(
+ dialog,
+ *Surface,
+ clipboardConfirmationConfirm,
+ self,
+ .{},
+ );
+ _ = ClipboardConfirmationDialog.signals.deny.connect(
+ dialog,
+ *Surface,
+ clipboardConfirmationDeny,
+ self,
+ .{},
+ );
+
+ dialog.present(self.as(gtk.Widget));
+ }
+
+ fn clipboardConfirmationConfirm(
+ dialog: *ClipboardConfirmationDialog,
+ remember: bool,
+ self: *Surface,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const surface = priv.core_surface orelse return;
+ const req = dialog.getRequest() orelse return;
+
+ // Handle remember
+ if (remember) switch (req.*) {
+ .osc_52_read => surface.config.clipboard_read = .allow,
+ .osc_52_write => surface.config.clipboard_write = .allow,
+ .paste => {},
+ };
+
+ // Get our text
+ const text_buf = dialog.getClipboardContents() orelse return;
+ var text_val = gobject.ext.Value.new(?[:0]const u8);
+ defer text_val.unset();
+ gobject.Object.getProperty(
+ text_buf.as(gobject.Object),
+ "text",
+ &text_val,
+ );
+ const text = gobject.ext.Value.get(
+ &text_val,
+ ?[:0]const u8,
+ ) orelse return;
+
+ surface.completeClipboardRequest(
+ req.*,
+ text,
+ true,
+ ) catch |err| {
+ log.warn("failed to complete clipboard request: {}", .{err});
+ };
+ }
+
+ fn clipboardConfirmationDeny(
+ dialog: *ClipboardConfirmationDialog,
+ remember: bool,
+ self: *Surface,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const surface = priv.core_surface orelse return;
+ const req = dialog.getRequest() orelse return;
+
+ // Handle remember
+ if (remember) switch (req.*) {
+ .osc_52_read => surface.config.clipboard_read = .deny,
+ .osc_52_write => surface.config.clipboard_write = .deny,
+ .paste => @panic("paste should not be able to be remembered"),
+ };
+ }
+
+ fn clipboardReadText(
+ source: ?*gobject.Object,
+ res: *gio.AsyncResult,
+ ud: ?*anyopaque,
+ ) callconv(.c) void {
+ const clipboard = gobject.ext.cast(
+ gdk.Clipboard,
+ source orelse return,
+ ) orelse return;
+ const req: *Request = @ptrCast(@alignCast(ud orelse return));
+
+ const alloc = Application.default().allocator();
+ defer alloc.destroy(req);
+
+ const self = req.self;
+ defer self.unref();
+
+ var gerr: ?*glib.Error = null;
+ const cstr_ = clipboard.readTextFinish(res, &gerr);
+ if (gerr) |err| {
+ defer err.free();
+ log.warn(
+ "failed to read clipboard err={s}",
+ .{err.f_message orelse "(no message)"},
+ );
+ return;
+ }
+ const cstr = cstr_ orelse return;
+ defer glib.free(cstr);
+ const str = std.mem.sliceTo(cstr, 0);
+
+ const surface = self.private().core_surface orelse return;
+ surface.completeClipboardRequest(
+ req.state,
+ str,
+ false,
+ ) catch |err| switch (err) {
+ error.UnsafePaste,
+ error.UnauthorizedPaste,
+ => {
+ showClipboardConfirmation(
+ self,
+ req.state,
+ str,
+ );
+ return;
+ },
+
+ else => {
+ log.warn(
+ "failed to complete clipboard request err={}",
+ .{err},
+ );
+ return;
+ },
+ };
+
+ Surface.signals.@"clipboard-read".impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+ }
+
+ /// The request we send as userdata to the clipboard read.
+ const Request = struct {
+ /// "Self" is reffed so we can't dispose it until the clipboard
+ /// read is complete. Callers must unref when done.
+ self: *Surface,
+ state: apprt.ClipboardRequest,
+ };
+};
+
+/// Compute a fraction [0.0, 1.0] from the supplied progress, which is clamped
+/// to [0, 100].
+fn computeFraction(progress: u8) f64 {
+ return @as(f64, @floatFromInt(std.math.clamp(progress, 0, 100))) / 100.0;
+}
+
+test "computeFraction" {
+ try std.testing.expectEqual(1.0, computeFraction(100));
+ try std.testing.expectEqual(1.0, computeFraction(255));
+ try std.testing.expectEqual(0.0, computeFraction(0));
+ try std.testing.expectEqual(0.5, computeFraction(50));
+}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig
new file mode 100644
index 00000000..d7dd41bc
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig
@@ -0,0 +1,264 @@
+const std = @import("std");
+const adw = @import("adw");
+const glib = @import("glib");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const adw_version = @import("../adw_version.zig");
+const apprt = @import("../../../apprt.zig");
+const gresource = @import("../build/gresource.zig");
+const i18n = @import("../../../os/main.zig").i18n;
+const Common = @import("../class.zig").Common;
+
+const log = std.log.scoped(.gtk_ghostty_surface_child_exited);
+
+pub const SurfaceChildExited = if (adw_version.supportsBanner())
+ SurfaceChildExitedBanner
+else
+ SurfaceChildExitedNoop;
+
+/// Child exited overlay based on adw.Banner introduced in
+/// Adwaita 1.3.
+const SurfaceChildExitedBanner = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = adw.Bin;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttySurfaceChildExited",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ pub const data = struct {
+ pub const name = "data";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*apprt.surface.Message.ChildExited,
+ .{
+ .accessor = C.privateBoxedFieldAccessor("data"),
+ },
+ );
+ };
+ };
+
+ pub const signals = struct {
+ /// Emitted when the banner would like to be closed.
+ pub const @"close-request" = struct {
+ pub const name = "close-request";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+ };
+
+ const Private = struct {
+ /// The child exited data sent by the apprt.
+ data: ?*apprt.surface.Message.ChildExited = null,
+
+ // Template bindings
+ banner: *adw.Banner,
+
+ pub var offset: c_int = 0;
+ };
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+ }
+
+ pub fn setData(
+ self: *Self,
+ data_: ?*const apprt.surface.Message.ChildExited,
+ ) void {
+ const priv = self.private();
+ if (priv.data) |v| glib.ext.destroy(v);
+ const data = data_ orelse {
+ priv.data = null;
+ return;
+ };
+
+ const ptr = glib.ext.create(apprt.surface.Message.ChildExited);
+ ptr.* = data.*;
+ priv.data = ptr;
+ self.as(gobject.Object).notifyByPspec(properties.data.impl.param_spec);
+ }
+
+ //---------------------------------------------------------------
+ // Signal handlers
+
+ fn propData(
+ self: *Self,
+ _: *gobject.ParamSpec,
+ _: ?*anyopaque,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const banner = priv.banner;
+ const data = priv.data orelse {
+ // Not localized on purpose.
+ banner.as(adw.Banner).setTitle("This is a bug in Ghostty. Please report it.");
+ return;
+ };
+ if (data.exit_code == 0) {
+ banner.as(adw.Banner).setTitle(i18n._("Command succeeded"));
+ self.as(gtk.Widget).addCssClass("normal");
+ self.as(gtk.Widget).removeCssClass("abnormal");
+ } else {
+ banner.as(adw.Banner).setTitle(i18n._("Command failed"));
+ self.as(gtk.Widget).removeCssClass("normal");
+ self.as(gtk.Widget).addCssClass("abnormal");
+ }
+ }
+
+ fn closeButtonClicked(
+ _: *adw.Banner,
+ self: *Self,
+ ) callconv(.c) void {
+ signals.@"close-request".impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+ }
+
+ //---------------------------------------------------------------
+ // Virtual methods
+
+ fn dispose(self: *Self) callconv(.c) void {
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ if (priv.data) |v| {
+ glib.ext.destroy(v);
+ priv.data = null;
+ }
+
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 3,
+ .name = "surface-child-exited",
+ }),
+ );
+
+ // Template bindings
+ class.bindTemplateChildPrivate("banner", .{});
+ class.bindTemplateCallback("clicked", &closeButtonClicked);
+ class.bindTemplateCallback("notify_data", &propData);
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.data.impl,
+ });
+
+ // Signals
+ signals.@"close-request".impl.register(.{});
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
+
+/// Empty widget that does nothing if we don't have a new enough
+/// Adwaita version to support the child exited banner.
+const SurfaceChildExitedNoop = extern struct {
+ /// Can be detected at comptime
+ pub const noop = true;
+
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = gtk.Widget;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttySurfaceChildExited",
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ });
+
+ pub const signals = struct {
+ pub const @"close-request" = struct {
+ pub const name = "close-request";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+ };
+
+ pub fn setData(
+ self: *Self,
+ _: ?*const apprt.surface.Message.ChildExited,
+ ) void {
+ signals.@"close-request".impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+ }
+
+ const C = Common(Self, null);
+ 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 {
+ _ = class;
+ signals.@"close-request".impl.register(.{});
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig
new file mode 100644
index 00000000..dd4f17d1
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig
@@ -0,0 +1,228 @@
+const std = @import("std");
+const adw = @import("adw");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+const gtk_version = @import("../gtk_version.zig");
+
+const gresource = @import("../build/gresource.zig");
+const Common = @import("../class.zig").Common;
+const Surface = @import("surface.zig").Surface;
+const Config = @import("config.zig").Config;
+
+const log = std.log.scoped(.gtk_ghostty_surface_scrolled_window);
+
+/// A wrapper widget that embeds a Surface inside a GtkScrolledWindow.
+/// This provides scrollbar functionality for the terminal surface.
+/// The surface property can be set during initialization or changed
+/// dynamically via the surface property.
+pub const SurfaceScrolledWindow = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = adw.Bin;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhostttySurfaceScrolledWindow",
+ .instanceInit = &init,
+ .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(
+ name,
+ Self,
+ ?*Config,
+ .{
+ .accessor = C.privateObjFieldAccessor("config"),
+ },
+ );
+ };
+
+ pub const surface = struct {
+ pub const name = "surface";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Surface,
+ .{
+ .accessor = .{
+ .getter = getSurfaceValue,
+ .setter = setSurfaceValue,
+ },
+ },
+ );
+ };
+ };
+
+ const Private = struct {
+ config: ?*Config = null,
+ config_binding: ?*gobject.Binding = null,
+ surface: ?*Surface = null,
+ scrolled_window: *gtk.ScrolledWindow,
+ pub var offset: c_int = 0;
+ };
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+ if (gtk_version.runtimeUntil(4, 20, 1)) self.disableKineticScroll();
+ }
+
+ fn disableKineticScroll(self: *Self) void {
+ // Until gtk 4.20.1 trackpads have kinetic scrolling behavior regardless
+ // of `Gtk.ScrolledWindow.kinetic_scrolling`. As a workaround, disable
+ // EventControllerScroll.kinetic
+ const controllers = self.private().scrolled_window.as(gtk.Widget).observeControllers();
+ defer controllers.unref();
+ var i: c_uint = 0;
+ while (controllers.getObject(i)) |obj| : (i += 1) {
+ defer obj.unref();
+ const controller = gobject.ext.cast(gtk.EventControllerScroll, obj) orelse continue;
+ var flags = controller.getFlags();
+ flags.kinetic = false;
+ controller.setFlags(flags);
+ }
+ }
+
+ fn dispose(self: *Self) callconv(.c) void {
+ const priv = self.private();
+
+ if (priv.config_binding) |binding| {
+ binding.as(gobject.Object).unref();
+ priv.config_binding = null;
+ }
+
+ if (priv.config) |v| {
+ v.unref();
+ priv.config = null;
+ }
+
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn getSurfaceValue(self: *Self, value: *gobject.Value) void {
+ gobject.ext.Value.set(
+ value,
+ self.private().surface,
+ );
+ }
+
+ fn setSurfaceValue(self: *Self, value: *const gobject.Value) void {
+ self.setSurface(gobject.ext.Value.get(
+ value,
+ ?*Surface,
+ ));
+ }
+
+ pub fn getSurface(self: *Self) ?*Surface {
+ return self.private().surface;
+ }
+
+ pub fn setSurface(self: *Self, surface_: ?*Surface) void {
+ const priv = self.private();
+
+ if (surface_ == priv.surface) return;
+
+ self.as(gobject.Object).freezeNotify();
+ defer self.as(gobject.Object).thawNotify();
+ self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec);
+
+ priv.surface = surface_;
+ }
+
+ fn closureScrollbarPolicy(
+ _: *Self,
+ config_: ?*Config,
+ ) callconv(.c) gtk.PolicyType {
+ const config = if (config_) |c| c.get() else return .automatic;
+ return switch (config.scrollbar) {
+ .never => .never,
+ .system => .automatic,
+ };
+ }
+
+ fn propSurface(
+ self: *Self,
+ _: *gobject.ParamSpec,
+ _: ?*anyopaque,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const child: *gtk.Widget = self.as(Parent).getChild().?;
+ const scrolled_window = gobject.ext.cast(gtk.ScrolledWindow, child).?;
+ scrolled_window.setChild(if (priv.surface) |s| s.as(gtk.Widget) else null);
+
+ // Unbind old config binding if it exists
+ if (priv.config_binding) |binding| {
+ binding.as(gobject.Object).unref();
+ priv.config_binding = null;
+ }
+
+ // Bind config from surface to our config property
+ if (priv.surface) |surface| {
+ priv.config_binding = surface.as(gobject.Object).bindProperty(
+ properties.config.name,
+ self.as(gobject.Object),
+ properties.config.name,
+ .{ .sync_create = true },
+ );
+ }
+ }
+
+ 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 {
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 5,
+ .name = "surface-scrolled-window",
+ }),
+ );
+
+ // Bindings
+ class.bindTemplateCallback("scrollbar_policy", &closureScrollbarPolicy);
+ class.bindTemplateCallback("notify_surface", &propSurface);
+ class.bindTemplateChildPrivate("scrolled_window", .{});
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.config.impl,
+ properties.surface.impl,
+ });
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig b/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig
new file mode 100644
index 00000000..0c60c8cc
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig
@@ -0,0 +1,592 @@
+const std = @import("std");
+const adw = @import("adw");
+const gio = @import("gio");
+const glib = @import("glib");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const configpkg = @import("../../../config.zig");
+const apprt = @import("../../../apprt.zig");
+const CoreSurface = @import("../../../Surface.zig");
+const ext = @import("../ext.zig");
+const gresource = @import("../build/gresource.zig");
+const Common = @import("../class.zig").Common;
+const Config = @import("config.zig").Config;
+const Application = @import("application.zig").Application;
+const SplitTree = @import("split_tree.zig").SplitTree;
+const Surface = @import("surface.zig").Surface;
+const TitleDialog = @import("title_dialog.zig").TitleDialog;
+
+const log = std.log.scoped(.gtk_ghostty_window);
+
+pub const Tab = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = gtk.Box;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyTab",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ /// The active surface is the surface that should be receiving all
+ /// surface-targeted actions. This is usually the focused surface,
+ /// but may also not be focused if the user has selected a non-surface
+ /// widget.
+ pub const @"active-surface" = struct {
+ pub const name = "active-surface";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Surface,
+ .{
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?*Surface,
+ .{
+ .getter = Self.getActiveSurface,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const config = struct {
+ pub const name = "config";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Config,
+ .{
+ .accessor = C.privateObjFieldAccessor("config"),
+ },
+ );
+ };
+
+ pub const @"split-tree" = struct {
+ pub const name = "split-tree";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*SplitTree,
+ .{
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?*SplitTree,
+ .{
+ .getter = getSplitTree,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const @"surface-tree" = struct {
+ pub const name = "surface-tree";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Surface.Tree,
+ .{
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?*Surface.Tree,
+ .{
+ .getter = getSurfaceTree,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const tooltip = struct {
+ pub const name = "tooltip";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = C.privateStringFieldAccessor("tooltip"),
+ },
+ );
+ };
+
+ pub const title = struct {
+ pub const name = "title";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = C.privateStringFieldAccessor("title"),
+ },
+ );
+ };
+ pub const @"title-override" = struct {
+ pub const name = "title-override";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = C.privateStringFieldAccessor("title_override"),
+ },
+ );
+ };
+ };
+
+ pub const signals = struct {
+ /// Emitted whenever the tab would like to be closed.
+ pub const @"close-request" = struct {
+ pub const name = "close-request";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{},
+ void,
+ );
+ };
+ };
+
+ const Private = struct {
+ /// The configuration that this surface is using.
+ config: ?*Config = null,
+
+ /// The title of this tab. This is usually bound to the active surface.
+ title: ?[:0]const u8 = null,
+
+ /// The manually overridden title from `promptTabTitle`.
+ title_override: ?[:0]const u8 = null,
+
+ /// The tooltip of this tab. This is usually bound to the active surface.
+ tooltip: ?[:0]const u8 = null,
+
+ // Template bindings
+ split_tree: *SplitTree,
+
+ pub var offset: c_int = 0;
+ };
+
+ /// Set the parent of this tab page. This only affects the first surface
+ /// ever created for a tab. If a surface was already created this does
+ /// nothing.
+ pub fn setParent(self: *Self, parent: *CoreSurface) void {
+ self.setParentWithContext(parent, .tab);
+ }
+
+ pub fn setParentWithContext(self: *Self, parent: *CoreSurface, context: apprt.surface.NewSurfaceContext) void {
+ if (self.getActiveSurface()) |surface| {
+ surface.setParent(parent, context);
+ }
+ }
+
+ pub fn new(config: ?*Config, overrides: struct {
+ command: ?configpkg.Command = null,
+ working_directory: ?[:0]const u8 = null,
+ title: ?[:0]const u8 = null,
+
+ pub const none: @This() = .{};
+ }) *Self {
+ const tab = gobject.ext.newInstance(Tab, .{});
+
+ const priv: *Private = tab.private();
+
+ if (config) |c| priv.config = c.ref();
+
+ // If our configuration is null then we get the configuration
+ // from the application.
+ if (priv.config == null) {
+ const app = Application.default();
+ priv.config = app.getConfig();
+ }
+
+ tab.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec);
+
+ // Create our initial surface in the split tree.
+ priv.split_tree.newSplit(.right, null, .{
+ .command = overrides.command,
+ .working_directory = overrides.working_directory,
+ .title = overrides.title,
+ }) catch |err| switch (err) {
+ error.OutOfMemory => {
+ // TODO: We should make our "no surfaces" state more aesthetically
+ // pleasing and show something like an "Oops, something went wrong"
+ // message. For now, this is incredibly unlikely.
+ @panic("oom");
+ },
+ };
+
+ return tab;
+ }
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+
+ // Init our actions
+ self.initActionMap();
+ }
+
+ fn initActionMap(self: *Self) void {
+ const s_param_type = glib.ext.VariantType.newFor([:0]const u8);
+ defer s_param_type.free();
+
+ const actions = [_]ext.actions.Action(Self){
+ .init("close", actionClose, s_param_type),
+ .init("ring-bell", actionRingBell, null),
+ .init("next-page", actionNextPage, null),
+ .init("previous-page", actionPreviousPage, null),
+ .init("prompt-tab-title", actionPromptTabTitle, null),
+ };
+
+ _ = ext.actions.addAsGroup(Self, self, "tab", &actions);
+ }
+
+ //---------------------------------------------------------------
+ // Properties
+
+ /// Overridden title. This will be generally be shown over the title
+ /// unless this is unset (null).
+ pub fn setTitleOverride(self: *Self, title: ?[:0]const u8) void {
+ const priv = self.private();
+ if (priv.title_override) |v| glib.free(@ptrCast(@constCast(v)));
+ priv.title_override = null;
+ if (title) |v| priv.title_override = glib.ext.dupeZ(u8, v);
+ self.as(gobject.Object).notifyByPspec(properties.@"title-override".impl.param_spec);
+ }
+ fn titleDialogSet(
+ _: *TitleDialog,
+ title_ptr: [*:0]const u8,
+ self: *Self,
+ ) callconv(.c) void {
+ const title = std.mem.span(title_ptr);
+ self.setTitleOverride(if (title.len == 0) null else title);
+ }
+ pub fn promptTabTitle(self: *Self) void {
+ const priv = self.private();
+ const dialog = TitleDialog.new(.tab, priv.title_override orelse priv.title);
+ _ = TitleDialog.signals.set.connect(
+ dialog,
+ *Self,
+ titleDialogSet,
+ self,
+ .{},
+ );
+
+ dialog.present(self.as(gtk.Widget));
+ }
+
+ /// Get the currently active surface. See the "active-surface" property.
+ /// This does not ref the value.
+ pub fn getActiveSurface(self: *Self) ?*Surface {
+ return self.getSplitTree().getActiveSurface();
+ }
+
+ /// Get the surface tree of this tab.
+ pub fn getSurfaceTree(self: *Self) ?*Surface.Tree {
+ const priv = self.private();
+ return priv.split_tree.getTree();
+ }
+
+ /// Get the split tree widget that is in this tab.
+ pub fn getSplitTree(self: *Self) *SplitTree {
+ const priv = self.private();
+ return priv.split_tree;
+ }
+
+ /// Returns true if this tab needs confirmation before quitting based
+ /// on the various Ghostty configurations.
+ pub fn getNeedsConfirmQuit(self: *Self) bool {
+ const tree = self.getSplitTree();
+ return tree.getNeedsConfirmQuit();
+ }
+
+ /// Get the tab view holding this tab, if any.
+ fn getTabView(self: *Self) ?*adw.TabView {
+ return ext.getAncestor(
+ adw.TabView,
+ self.as(gtk.Widget),
+ );
+ }
+
+ /// Get the tab page holding this tab, if any.
+ fn getTabPage(self: *Self) ?*adw.TabPage {
+ const tab_view = self.getTabView() orelse return null;
+ return tab_view.getPage(self.as(gtk.Widget));
+ }
+
+ //---------------------------------------------------------------
+ // Virtual methods
+
+ fn dispose(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ if (priv.config) |v| {
+ v.unref();
+ priv.config = null;
+ }
+
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ if (priv.tooltip) |v| {
+ glib.free(@ptrCast(@constCast(v)));
+ priv.tooltip = null;
+ }
+ if (priv.title) |v| {
+ glib.free(@ptrCast(@constCast(v)));
+ priv.title = null;
+ }
+ if (priv.title_override) |v| {
+ glib.free(@ptrCast(@constCast(v)));
+ priv.title_override = null;
+ }
+
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+ //---------------------------------------------------------------
+ // Signal handlers
+
+ fn propSplitTree(
+ _: *SplitTree,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec);
+
+ // If our tree is empty we close the tab.
+ const tree: *const Surface.Tree = self.getSurfaceTree() orelse &.empty;
+ if (tree.isEmpty()) {
+ signals.@"close-request".impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+ return;
+ }
+ }
+
+ fn propActiveSurface(
+ _: *SplitTree,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
+ }
+
+ fn actionClose(
+ _: *gio.SimpleAction,
+ param_: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ const param = param_ orelse {
+ log.warn("tab.close-tab called without a parameter", .{});
+ return;
+ };
+
+ var str: ?[*:0]const u8 = null;
+ param.get("&s", &str);
+
+ const tab_view = self.getTabView() orelse return;
+ const page = tab_view.getPage(self.as(gtk.Widget));
+
+ const mode = std.meta.stringToEnum(
+ apprt.action.CloseTabMode,
+ std.mem.span(
+ str orelse {
+ log.warn("invalid mode provided to tab.close-tab", .{});
+ return;
+ },
+ ),
+ ) orelse {
+ // Need to be defensive here since actions can be triggered externally.
+ log.warn("invalid mode provided to tab.close-tab: {s}", .{str.?});
+ return;
+ };
+
+ // Delegate to our parent to handle this, since this will emit
+ // a close-page signal that the parent can intercept.
+ switch (mode) {
+ .this => tab_view.closePage(page),
+ .other => tab_view.closeOtherPages(page),
+ .right => tab_view.closePagesAfter(page),
+ }
+ }
+
+ fn actionPromptTabTitle(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ self.promptTabTitle();
+ }
+
+ fn actionRingBell(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ // Future note: I actually don't like this logic living here at all.
+ // I think a better approach will be for the ring bell action to
+ // specify its sending surface and then do all this in the window.
+
+ // If the page is selected already we don't mark it as needing
+ // attention. We only want to mark unfocused pages. This will then
+ // clear when the page is selected.
+ const page = self.getTabPage() orelse return;
+ if (page.getSelected() != 0) return;
+ page.setNeedsAttention(@intFromBool(true));
+ }
+
+ /// Select the next tab page.
+ fn actionNextPage(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ const tab_view = self.getTabView() orelse return;
+ _ = tab_view.selectNextPage();
+ }
+
+ /// Select the previous tab page.
+ fn actionPreviousPage(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ const tab_view = self.getTabView() orelse return;
+ _ = tab_view.selectPreviousPage();
+ }
+
+ fn closureComputedTitle(
+ _: *Self,
+ config_: ?*Config,
+ terminal_: ?[*:0]const u8,
+ surface_override_: ?[*:0]const u8,
+ tab_override_: ?[*:0]const u8,
+ zoomed_: c_int,
+ bell_ringing_: c_int,
+ _: *gobject.ParamSpec,
+ ) callconv(.c) ?[*:0]const u8 {
+ const zoomed = zoomed_ != 0;
+ const bell_ringing = bell_ringing_ != 0;
+
+ // Our plain title is the manually tab overridden title if it exists,
+ // otherwise the overridden title if it exists, otherwise
+ // the terminal title if it exists, otherwise a default string.
+ const plain = plain: {
+ const default = "Ghostty";
+ const config_title: ?[*:0]const u8 = title: {
+ const config = config_ orelse break :title null;
+ break :title config.get().title orelse null;
+ };
+
+ const plain = tab_override_ orelse
+ surface_override_ orelse
+ terminal_ orelse
+ config_title orelse
+ break :plain default;
+ break :plain std.mem.span(plain);
+ };
+
+ // We don't need a config in every case, but if we don't have a config
+ // let's just assume something went terribly wrong and use our
+ // default title. Its easier then guarding on the config existing
+ // in every case for something so unlikely.
+ const config = if (config_) |v| v.get() else {
+ log.warn("config unavailable for computed title, likely bug", .{});
+ return glib.ext.dupeZ(u8, plain);
+ };
+
+ // Use an allocator to build up our string as we write it.
+ var buf: std.Io.Writer.Allocating = .init(Application.default().allocator());
+ defer buf.deinit();
+
+ // If our bell is ringing, then we prefix the bell icon to the title.
+ if (bell_ringing and config.@"bell-features".title) {
+ buf.writer.writeAll("🔔 ") catch {};
+ }
+
+ // If we're zoomed, prefix with the magnifying glass emoji.
+ if (zoomed) {
+ buf.writer.writeAll("🔍 ") catch {};
+ }
+
+ buf.writer.writeAll(plain) catch return glib.ext.dupeZ(u8, plain);
+ return glib.ext.dupeZ(u8, buf.written());
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gobject.ext.ensureType(SplitTree);
+ gobject.ext.ensureType(Surface);
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 5,
+ .name = "tab",
+ }),
+ );
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.@"active-surface".impl,
+ properties.config.impl,
+ properties.@"split-tree".impl,
+ properties.@"surface-tree".impl,
+ properties.title.impl,
+ properties.@"title-override".impl,
+ properties.tooltip.impl,
+ });
+
+ // Bindings
+ class.bindTemplateChildPrivate("split_tree", .{});
+
+ // Template Callbacks
+ class.bindTemplateCallback("computed_title", &closureComputedTitle);
+ class.bindTemplateCallback("notify_active_surface", &propActiveSurface);
+ class.bindTemplateCallback("notify_tree", &propSplitTree);
+
+ // Signals
+ signals.@"close-request".impl.register(.{});
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig
new file mode 100644
index 00000000..ac95ae4b
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig
@@ -0,0 +1,234 @@
+const std = @import("std");
+const adw = @import("adw");
+const gio = @import("gio");
+const glib = @import("glib");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const gresource = @import("../build/gresource.zig");
+const i18n = @import("../../../os/main.zig").i18n;
+const ext = @import("../ext.zig");
+const Common = @import("../class.zig").Common;
+const Dialog = @import("dialog.zig").Dialog;
+
+const log = std.log.scoped(.gtk_ghostty_title_dialog);
+
+pub const TitleDialog = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = adw.AlertDialog;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyTitleDialog",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ pub const target = struct {
+ pub const name = "target";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ Target,
+ .{
+ .default = .surface,
+ .accessor = gobject.ext
+ .privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "target",
+ ),
+ },
+ );
+ };
+ pub const @"initial-value" = struct {
+ pub const name = "initial-value";
+ pub const get = impl.get;
+ pub const set = impl.set;
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = C.privateStringFieldAccessor("initial_value"),
+ },
+ );
+ };
+ };
+
+ pub const signals = struct {
+ /// Set the title to the given value.
+ pub const set = struct {
+ pub const name = "set";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{[*:0]const u8},
+ void,
+ );
+ };
+ };
+
+ const Private = struct {
+ /// The initial value of the entry field.
+ initial_value: ?[:0]const u8 = null,
+
+ // Template bindings
+ target: Target,
+ entry: *gtk.Entry,
+
+ pub var offset: c_int = 0;
+ };
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+ }
+
+ pub fn new(target: Target, initial_value: ?[:0]const u8) *Self {
+ return gobject.ext.newInstance(Self, .{ .target = target, .@"initial-value" = initial_value });
+ }
+
+ pub fn present(self: *Self, parent_: *gtk.Widget) void {
+ // If we have a window we can attach to, we prefer that.
+ const parent: *gtk.Widget = if (ext.getAncestor(
+ adw.ApplicationWindow,
+ parent_,
+ )) |window|
+ window.as(gtk.Widget)
+ else if (ext.getAncestor(
+ adw.Window,
+ parent_,
+ )) |window|
+ window.as(gtk.Widget)
+ else
+ parent_;
+
+ // Set our initial value
+ const priv = self.private();
+ if (priv.initial_value) |v| {
+ priv.entry.getBuffer().setText(v, -1);
+ }
+
+ // Set the title for the dialog
+ self.as(Dialog.Parent).setHeading(priv.target.title());
+
+ // Show it. We could also just use virtual methods to bind to
+ // response but this is pretty simple.
+ self.as(adw.AlertDialog).choose(
+ parent,
+ null,
+ alertDialogReady,
+ self,
+ );
+ }
+
+ fn alertDialogReady(
+ _: ?*gobject.Object,
+ result: *gio.AsyncResult,
+ ud: ?*anyopaque,
+ ) callconv(.c) void {
+ const self: *Self = @ptrCast(@alignCast(ud));
+ const response = self.as(adw.AlertDialog).chooseFinish(result);
+
+ // If we didn't hit "okay" then we do nothing.
+ if (std.mem.orderZ(u8, "ok", response) != .eq) return;
+
+ // Emit our signal with the new title.
+ const title = std.mem.span(self.private().entry.getBuffer().getText());
+ signals.set.impl.emit(
+ self,
+ null,
+ .{title.ptr},
+ null,
+ );
+ }
+
+ fn dispose(self: *Self) callconv(.c) void {
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ if (priv.initial_value) |v| {
+ glib.free(@ptrCast(@constCast(v)));
+ priv.initial_value = null;
+ }
+
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 5,
+ .name = "title-dialog",
+ }),
+ );
+
+ // Signals
+ signals.set.impl.register(.{});
+
+ // Bindings
+ class.bindTemplateChildPrivate("entry", .{});
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.@"initial-value".impl,
+ properties.target.impl,
+ });
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};
+
+pub const Target = enum(c_int) {
+ surface,
+ tab,
+ pub fn title(self: Target) [*:0]const u8 {
+ return switch (self) {
+ .surface => i18n._("Change Terminal Title"),
+ .tab => i18n._("Change Tab Title"),
+ };
+ }
+
+ pub const getGObjectType = gobject.ext.defineEnum(
+ Target,
+ .{ .name = "GhosttyTitleDialogTarget" },
+ );
+};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig
new file mode 100644
index 00000000..bf2a2fe7
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig
@@ -0,0 +1,2111 @@
+const std = @import("std");
+const build_config = @import("../../../build_config.zig");
+const assert = @import("../../../quirks.zig").inlineAssert;
+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 i18n = @import("../../../os/main.zig").i18n;
+const apprt = @import("../../../apprt.zig");
+const configpkg = @import("../../../config.zig");
+const TitlebarStyle = configpkg.Config.GtkTitlebarStyle;
+const input = @import("../../../input.zig");
+const CoreSurface = @import("../../../Surface.zig");
+const ext = @import("../ext.zig");
+const gtk_version = @import("../gtk_version.zig");
+const adw_version = @import("../adw_version.zig");
+const gresource = @import("../build/gresource.zig");
+const winprotopkg = @import("../winproto.zig");
+const Common = @import("../class.zig").Common;
+const Config = @import("config.zig").Config;
+const Application = @import("application.zig").Application;
+const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
+const SplitTree = @import("split_tree.zig").SplitTree;
+const Surface = @import("surface.zig").Surface;
+const Tab = @import("tab.zig").Tab;
+const DebugWarning = @import("debug_warning.zig").DebugWarning;
+const CommandPalette = @import("command_palette.zig").CommandPalette;
+const WeakRef = @import("../weak_ref.zig").WeakRef;
+
+const log = std.log.scoped(.gtk_ghostty_window);
+
+pub const Window = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = adw.ApplicationWindow;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyWindow",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ /// The active surface is the focus that should be receiving all
+ /// surface-targeted actions. This is usually the focused surface,
+ /// but may also not be focused if the user has selected a non-surface
+ /// widget.
+ pub const @"active-surface" = struct {
+ pub const name = "active-surface";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Surface,
+ .{
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ ?*Surface,
+ .{
+ .getter = Self.getActiveSurface,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const config = struct {
+ pub const name = "config";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?*Config,
+ .{
+ .accessor = C.privateObjFieldAccessor("config"),
+ },
+ );
+ };
+
+ pub const debug = struct {
+ pub const name = "debug";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = build_config.is_debug,
+ .accessor = gobject.ext.typedAccessor(Self, bool, .{
+ .getter = struct {
+ pub fn getter(_: *Self) bool {
+ return build_config.is_debug;
+ }
+ }.getter,
+ }),
+ },
+ );
+ };
+
+ pub const @"titlebar-style" = struct {
+ pub const name = "titlebar-style";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ TitlebarStyle,
+ .{
+ .default = .native,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ TitlebarStyle,
+ .{
+ .getter = Self.getTitlebarStyle,
+ },
+ ),
+ },
+ );
+ };
+
+ pub const @"headerbar-visible" = struct {
+ pub const name = "headerbar-visible";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = true,
+ .accessor = gobject.ext.typedAccessor(Self, bool, .{
+ .getter = Self.getHeaderbarVisible,
+ }),
+ },
+ );
+ };
+
+ pub const @"quick-terminal" = struct {
+ pub const name = "quick-terminal";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = true,
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "quick_terminal",
+ ),
+ },
+ );
+ };
+
+ pub const @"tabs-autohide" = struct {
+ pub const name = "tabs-autohide";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = true,
+ .accessor = gobject.ext.typedAccessor(Self, bool, .{
+ .getter = Self.getTabsAutohide,
+ }),
+ },
+ );
+ };
+
+ pub const @"tabs-wide" = struct {
+ pub const name = "tabs-wide";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = true,
+ .accessor = gobject.ext.typedAccessor(Self, bool, .{
+ .getter = Self.getTabsWide,
+ }),
+ },
+ );
+ };
+
+ pub const @"tabs-visible" = struct {
+ pub const name = "tabs-visible";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = true,
+ .accessor = gobject.ext.typedAccessor(Self, bool, .{
+ .getter = Self.getTabsVisible,
+ }),
+ },
+ );
+ };
+
+ pub const @"toolbar-style" = struct {
+ pub const name = "toolbar-style";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ adw.ToolbarStyle,
+ .{
+ .default = .raised,
+ .accessor = gobject.ext.typedAccessor(
+ Self,
+ adw.ToolbarStyle,
+ .{
+ .getter = Self.getToolbarStyle,
+ },
+ ),
+ },
+ );
+ };
+ };
+
+ const Private = struct {
+ /// Whether this window is a quick terminal. If it is then it
+ /// behaves slightly differently under certain scenarios.
+ quick_terminal: bool = false,
+
+ /// The window decoration override. If this is not set then we'll
+ /// inherit whatever the config has. This allows overriding the
+ /// config on a per-window basis.
+ window_decoration: ?configpkg.WindowDecoration = null,
+
+ /// Binding group for our active tab.
+ tab_bindings: *gobject.BindingGroup,
+
+ /// The configuration that this surface is using.
+ config: ?*Config = null,
+
+ /// State and logic for windowing protocol for a window.
+ winproto: winprotopkg.Window,
+
+ /// Kind of hacky to have this but this lets us know if we've
+ /// initialized any single surface yet. We need this because we
+ /// gate default size on this so that we don't resize the window
+ /// after surfaces already exist.
+ ///
+ /// I think long term we can probably get rid of this by implementing
+ /// a property or method that gets us all the surfaces in all the
+ /// tabs and checking if we have zero or one that isn't initialized.
+ ///
+ /// For now, this logic is more similar to our legacy GTK side.
+ surface_init: bool = false,
+
+ /// See tabOverviewOpen for why we have this.
+ tab_overview_focus_timer: ?c_uint = null,
+
+ /// A weak reference to a command palette.
+ command_palette: WeakRef(CommandPalette) = .empty,
+
+ /// Tab page that the context menu was opened for.
+ /// setup by `setup-menu`.
+ context_menu_page: ?*adw.TabPage = null,
+
+ // Template bindings
+ tab_overview: *adw.TabOverview,
+ tab_bar: *adw.TabBar,
+ tab_view: *adw.TabView,
+ toolbar: *adw.ToolbarView,
+ toast_overlay: *adw.ToastOverlay,
+
+ pub var offset: c_int = 0;
+ };
+
+ pub fn new(
+ app: *Application,
+ overrides: struct {
+ title: ?[:0]const u8 = null,
+
+ pub const none: @This() = .{};
+ },
+ ) *Self {
+ const win = gobject.ext.newInstance(Self, .{
+ .application = app,
+ });
+
+ if (overrides.title) |title| {
+ // If the overrides have a title set, we set that immediately
+ // so that any applications inspecting the window states see an
+ // immediate title set when the window appears, rather than waiting
+ // possibly a few event loop ticks for it to sync from the surface.
+ win.as(gtk.Window).setTitle(title);
+ }
+
+ return win;
+ }
+
+ fn init(self: *Self, _: *Class) callconv(.c) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+
+ // If our configuration is null then we get the configuration
+ // from the application.
+ const priv = self.private();
+
+ const config = config: {
+ if (priv.config) |config| break :config config.get();
+ const app = Application.default();
+ const config = app.getConfig();
+ priv.config = config;
+ break :config config.get();
+ };
+
+ // We initialize our windowing protocol to none because we can't
+ // actually initialize this until we get realized.
+ priv.winproto = .none;
+
+ // Add our dev CSS class if we're in debug mode.
+ if (comptime build_config.is_debug) {
+ self.as(gtk.Widget).addCssClass("devel");
+ }
+
+ // Setup our tab binding group. This ensures certain properties
+ // are only synced from the currently active tab.
+ priv.tab_bindings = gobject.BindingGroup.new();
+ priv.tab_bindings.bind("title", self.as(gobject.Object), "title", .{});
+
+ // Set our window icon. We can't set this in the blueprint file
+ // because its dependent on the build config.
+ self.as(gtk.Window).setIconName(build_config.bundle_id);
+
+ // Initialize our actions
+ self.initActionMap();
+
+ // Start states based on config.
+ if (config.maximize) self.as(gtk.Window).maximize();
+ if (config.fullscreen != .false) self.as(gtk.Window).fullscreen();
+
+ // If we have an explicit title set, we set that immediately
+ // so that any applications inspecting the window states see
+ // an immediate title set when the window appears, rather than
+ // waiting possibly a few event loop ticks for it to sync from
+ // the surface.
+ if (config.title) |title| {
+ self.as(gtk.Window).setTitle(title);
+ }
+
+ // We always sync our appearance at the end because loading our
+ // config and such can affect our bindings which are setup initially
+ // in initTemplate.
+ self.syncAppearance();
+
+ // We need to do this so that the title initializes properly,
+ // I think because its a dynamic getter.
+ self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
+ }
+
+ /// Setup our action map.
+ fn initActionMap(self: *Self) void {
+ const s_variant_type = glib.ext.VariantType.newFor([:0]const u8);
+ defer s_variant_type.free();
+
+ const actions = [_]ext.actions.Action(Self){
+ .init("about", actionAbout, null),
+ .init("close", actionClose, null),
+ .init("close-tab", actionCloseTab, s_variant_type),
+ .init("new-tab", actionNewTab, null),
+ .init("new-window", actionNewWindow, null),
+ .init("prompt-surface-title", actionPromptSurfaceTitle, null),
+ .init("prompt-tab-title", actionPromptTabTitle, null),
+ .init("prompt-context-tab-title", actionPromptContextTabTitle, null),
+ .init("ring-bell", actionRingBell, null),
+ .init("split-right", actionSplitRight, null),
+ .init("split-left", actionSplitLeft, null),
+ .init("split-up", actionSplitUp, null),
+ .init("split-down", actionSplitDown, null),
+ .init("copy", actionCopy, null),
+ .init("paste", actionPaste, null),
+ .init("reset", actionReset, null),
+ .init("clear", actionClear, null),
+ // TODO: accept the surface that toggled the command palette
+ .init("toggle-command-palette", actionToggleCommandPalette, null),
+ .init("toggle-inspector", actionToggleInspector, null),
+ };
+
+ ext.actions.add(Self, self, &actions);
+ }
+
+ /// Winproto backend for this window.
+ pub fn winproto(self: *Self) *winprotopkg.Window {
+ return &self.private().winproto;
+ }
+
+ /// Create a new tab with the given parent. The tab will be inserted
+ /// at the position dictated by the `window-new-tab-position` config.
+ /// The new tab will be selected.
+ pub fn newTab(self: *Self, parent_: ?*CoreSurface) void {
+ _ = self.newTabPage(parent_, .tab, .none);
+ }
+
+ pub fn newTabForWindow(
+ self: *Self,
+ parent_: ?*CoreSurface,
+ overrides: struct {
+ command: ?configpkg.Command = null,
+ working_directory: ?[:0]const u8 = null,
+ title: ?[:0]const u8 = null,
+
+ pub const none: @This() = .{};
+ },
+ ) void {
+ _ = self.newTabPage(
+ parent_,
+ .window,
+ .{
+ .command = overrides.command,
+ .working_directory = overrides.working_directory,
+ .title = overrides.title,
+ },
+ );
+ }
+
+ fn newTabPage(
+ self: *Self,
+ parent_: ?*CoreSurface,
+ context: apprt.surface.NewSurfaceContext,
+ overrides: struct {
+ command: ?configpkg.Command = null,
+ working_directory: ?[:0]const u8 = null,
+ title: ?[:0]const u8 = null,
+
+ pub const none: @This() = .{};
+ },
+ ) *adw.TabPage {
+ const priv: *Private = self.private();
+ const tab_view = priv.tab_view;
+
+ // Create our new tab object
+ const tab = Tab.new(
+ priv.config,
+ .{
+ .command = overrides.command,
+ .working_directory = overrides.working_directory,
+ .title = overrides.title,
+ },
+ );
+
+ if (parent_) |p| {
+ // For a new window's first tab, inherit the parent's initial size hints.
+ if (context == .window) {
+ surfaceInit(p.rt_surface.gobj(), self);
+ }
+ tab.setParentWithContext(p, context);
+ }
+
+ // Get the position that we should insert the new tab at.
+ const config = if (priv.config) |v| v.get() else {
+ // If we don't have a config we just append it at the end.
+ // This should never happen.
+ return tab_view.append(tab.as(gtk.Widget));
+ };
+ const position = switch (config.@"window-new-tab-position") {
+ .current => current: {
+ const selected = tab_view.getSelectedPage() orelse
+ break :current tab_view.getNPages();
+ const current = tab_view.getPagePosition(selected);
+ break :current current + 1;
+ },
+
+ .end => tab_view.getNPages(),
+ };
+
+ // Add the page and select it
+ const page = tab_view.insert(tab.as(gtk.Widget), position);
+ tab_view.setSelectedPage(page);
+
+ // Create some property bindings
+ _ = tab.as(gobject.Object).bindProperty(
+ "title",
+ page.as(gobject.Object),
+ "title",
+ .{ .sync_create = true },
+ );
+ _ = tab.as(gobject.Object).bindProperty(
+ "tooltip",
+ page.as(gobject.Object),
+ "tooltip",
+ .{ .sync_create = true },
+ );
+
+ // Bind signals
+ const split_tree = tab.getSplitTree();
+ _ = SplitTree.signals.changed.connect(
+ split_tree,
+ *Self,
+ tabSplitTreeChanged,
+ self,
+ .{},
+ );
+
+ // Run an initial notification for the surface tree so we can setup
+ // initial state.
+ tabSplitTreeChanged(
+ split_tree,
+ null,
+ split_tree.getTree(),
+ self,
+ );
+
+ return page;
+ }
+
+ pub const SelectTab = union(enum) {
+ previous,
+ next,
+ last,
+ n: usize,
+ };
+
+ /// Select the tab as requested. Returns true if the tab selection
+ /// changed.
+ pub fn selectTab(self: *Self, n: SelectTab) bool {
+ const priv = self.private();
+ const tab_view = priv.tab_view;
+
+ // Get our current tab numeric position
+ const selected = tab_view.getSelectedPage() orelse return false;
+ const current = tab_view.getPagePosition(selected);
+
+ // Get our total
+ const total = tab_view.getNPages();
+
+ const goto: c_int = switch (n) {
+ .previous => if (current > 0)
+ current - 1
+ else
+ total - 1,
+
+ .next => if (current < total - 1)
+ current + 1
+ else
+ 0,
+
+ .last => total - 1,
+
+ .n => |v| n: {
+ // 1-indexed
+ if (v == 0) return false;
+
+ const n_int = std.math.cast(
+ c_int,
+ v,
+ ) orelse return false;
+ break :n @min(n_int - 1, total - 1);
+ },
+ };
+ assert(goto >= 0);
+ assert(goto < total);
+
+ // If our target is the same as our current then we do nothing.
+ if (goto == current) return false;
+
+ // Add the page and select it
+ const page = tab_view.getNthPage(goto);
+ tab_view.setSelectedPage(page);
+
+ return true;
+ }
+
+ /// Move the tab containing the given surface by the given amount.
+ /// Returns if this affected any tab positioning.
+ pub fn moveTab(
+ self: *Self,
+ surface: *Surface,
+ amount: isize,
+ ) bool {
+ const priv = self.private();
+ const tab_view = priv.tab_view;
+
+ // If we have one tab we never move.
+ const total = tab_view.getNPages();
+ if (total == 1) return false;
+
+ // Get the tab that contains the given surface.
+ const tab = ext.getAncestor(
+ Tab,
+ surface.as(gtk.Widget),
+ ) orelse return false;
+
+ // Get the page position that contains the tab.
+ const page = tab_view.getPage(tab.as(gtk.Widget));
+ const pos = tab_view.getPagePosition(page);
+
+ // Move it
+ const desired_pos: c_int = desired: {
+ const initial: c_int = @intCast(pos + amount);
+ const max = total - 1;
+ break :desired if (initial < 0)
+ max + initial + 1
+ else if (initial > max)
+ initial - max - 1
+ else
+ initial;
+ };
+ assert(desired_pos >= 0);
+ assert(desired_pos < total);
+
+ return tab_view.reorderPage(page, desired_pos) != 0;
+ }
+
+ pub fn toggleTabOverview(self: *Self) void {
+ const priv = self.private();
+ const tab_overview = priv.tab_overview;
+ const is_open = tab_overview.getOpen() != 0;
+ tab_overview.setOpen(@intFromBool(!is_open));
+ }
+
+ /// Toggle the visible property.
+ pub fn toggleVisibility(self: *Self) void {
+ const widget = self.as(gtk.Widget);
+ widget.setVisible(@intFromBool(widget.isVisible() == 0));
+ }
+
+ /// Updates various appearance properties. This should always be safe
+ /// to call multiple times. This should be called whenever a change
+ /// happens that might affect how the window appears (config change,
+ /// fullscreen, etc.).
+ fn syncAppearance(self: *Self) void {
+ const priv = self.private();
+ const widget = self.as(gtk.Widget);
+
+ // Toggle style classes based on whether we're using CSDs or SSDs.
+ //
+ // These classes are defined in the gtk.Window documentation:
+ // https://docs.gtk.org/gtk4/class.Window.html#css-nodes.
+ {
+ // Reset all style classes first
+ inline for (&.{
+ "ssd",
+ "csd",
+ "solid-csd",
+ "no-border-radius",
+ }) |class|
+ widget.removeCssClass(class);
+
+ const csd_enabled = priv.winproto.clientSideDecorationEnabled();
+ self.as(gtk.Window).setDecorated(@intFromBool(csd_enabled));
+
+ if (csd_enabled) {
+ const display = widget.getDisplay();
+
+ // We do the exact same check GTK is doing internally and toggle
+ // either the `csd` or `solid-csd` style, based on whether the user's
+ // window manager is deemed _non-compositing_.
+ //
+ // In practice this only impacts users of traditional X11 window
+ // managers (e.g. i3, dwm, awesomewm, etc.) and not X11 desktop
+ // environments or Wayland compositors/DEs.
+ if (display.isRgba() != 0 and display.isComposited() != 0) {
+ widget.addCssClass("csd");
+ } else {
+ widget.addCssClass("solid-csd");
+ }
+ } else {
+ widget.addCssClass("ssd");
+ // Fix any artifacting that may occur in window corners.
+ widget.addCssClass("no-border-radius");
+ }
+ }
+
+ // Trigger all our dynamic properties that depend on the config.
+ inline for (&.{
+ "headerbar-visible",
+ "tabs-autohide",
+ "tabs-visible",
+ "tabs-wide",
+ "toolbar-style",
+ "titlebar-style",
+ }) |key| {
+ self.as(gobject.Object).notifyByPspec(
+ @field(properties, key).impl.param_spec,
+ );
+ }
+
+ // Remainder uses the config
+ const config = if (priv.config) |v| v.get() else return;
+
+ // Only add a solid background if we're opaque.
+ self.toggleCssClass(
+ "background",
+ config.@"background-opacity" >= 1,
+ );
+
+ // Apply class to color headerbar if window-theme is set to `ghostty` and
+ // GTK version is before 4.16. The conditional is because above 4.16
+ // we use GTK CSS color variables.
+ self.toggleCssClass(
+ "window-theme-ghostty",
+ !gtk_version.atLeast(4, 16, 0) and
+ config.@"window-theme" == .ghostty,
+ );
+
+ // Move the tab bar to the proper location.
+ priv.toolbar.remove(priv.tab_bar.as(gtk.Widget));
+ switch (config.@"gtk-tabs-location") {
+ .top => priv.toolbar.addTopBar(priv.tab_bar.as(gtk.Widget)),
+ .bottom => priv.toolbar.addBottomBar(priv.tab_bar.as(gtk.Widget)),
+ }
+
+ // Do our window-protocol specific appearance sync.
+ priv.winproto.syncAppearance() catch |err| {
+ log.warn("failed to sync winproto appearance error={}", .{err});
+ };
+ }
+
+ /// Sync the state of any actions on this window.
+ fn syncActions(self: *Self) void {
+ const has_selection = selection: {
+ const surface = self.getActiveSurface() orelse
+ break :selection false;
+ const core_surface = surface.core() orelse
+ break :selection false;
+ break :selection core_surface.hasSelection();
+ };
+
+ const action_map: *gio.ActionMap = gobject.ext.cast(
+ gio.ActionMap,
+ self,
+ ) orelse return;
+ const action: *gio.SimpleAction = gobject.ext.cast(
+ gio.SimpleAction,
+ action_map.lookupAction("copy") orelse return,
+ ) orelse return;
+ action.setEnabled(@intFromBool(has_selection));
+ }
+
+ fn toggleCssClass(self: *Self, class: [:0]const u8, value: bool) void {
+ const widget = self.as(gtk.Widget);
+ if (value)
+ widget.addCssClass(class.ptr)
+ else
+ widget.removeCssClass(class.ptr);
+ }
+
+ /// Perform a binding action on the window's active surface.
+ fn performBindingAction(
+ self: *Self,
+ action: input.Binding.Action,
+ ) void {
+ const surface = self.getActiveSurface() orelse return;
+ const core_surface = surface.core() orelse return;
+ _ = core_surface.performBindingAction(action) catch |err| {
+ log.warn("error performing binding action error={}", .{err});
+ return;
+ };
+ }
+
+ /// Queue a simple text-based toast. All text-based toasts share the
+ /// same timeout for consistency.
+ ///
+ // This is not `pub` because we should be using signals emitted by
+ // other widgets to trigger our toasts. Other objects should not
+ // trigger toasts directly.
+ fn addToast(self: *Self, title: [*:0]const u8) void {
+ const toast = adw.Toast.new(title);
+ toast.setTimeout(3);
+ self.private().toast_overlay.addToast(toast);
+ }
+
+ fn connectSurfaceHandlers(
+ self: *Self,
+ tree: *const Surface.Tree,
+ ) void {
+ const priv = self.private();
+ var it = tree.iterator();
+ while (it.next()) |entry| {
+ const surface = entry.view;
+ // Before adding any new signal handlers, disconnect any that we may
+ // have added before. Otherwise we may get multiple handlers for the
+ // same signal.
+ _ = gobject.signalHandlersDisconnectMatched(
+ surface.as(gobject.Object),
+ .{ .data = true },
+ 0,
+ 0,
+ null,
+ null,
+ self,
+ );
+
+ _ = Surface.signals.@"present-request".connect(
+ surface,
+ *Self,
+ surfacePresentRequest,
+ self,
+ .{},
+ );
+ _ = Surface.signals.@"clipboard-write".connect(
+ surface,
+ *Self,
+ surfaceClipboardWrite,
+ self,
+ .{},
+ );
+ _ = Surface.signals.menu.connect(
+ surface,
+ *Self,
+ surfaceMenu,
+ self,
+ .{},
+ );
+ _ = Surface.signals.@"toggle-fullscreen".connect(
+ surface,
+ *Self,
+ surfaceToggleFullscreen,
+ self,
+ .{},
+ );
+ _ = Surface.signals.@"toggle-maximize".connect(
+ surface,
+ *Self,
+ surfaceToggleMaximize,
+ self,
+ .{},
+ );
+
+ // If we've never had a surface initialize yet, then we register
+ // this signal. Its theoretically possible to launch multiple surfaces
+ // before init so we could register this on multiple and that is not
+ // a problem because we'll check the flag again in each handler.
+ if (!priv.surface_init) {
+ _ = Surface.signals.init.connect(
+ surface,
+ *Self,
+ surfaceInit,
+ self,
+ .{},
+ );
+ }
+ }
+ }
+
+ /// Disconnect all the surface handlers for the given tree. This should
+ /// be called whenever a tree is no longer present in the window, e.g.
+ /// when a tab is detached or the tree changes.
+ fn disconnectSurfaceHandlers(
+ self: *Self,
+ tree: *const Surface.Tree,
+ ) void {
+ var it = tree.iterator();
+ while (it.next()) |entry| {
+ const surface = entry.view;
+ _ = gobject.signalHandlersDisconnectMatched(
+ surface.as(gobject.Object),
+ .{ .data = true },
+ 0,
+ 0,
+ null,
+ null,
+ self,
+ );
+ }
+ }
+
+ //---------------------------------------------------------------
+ // Properties
+
+ /// Whether this terminal is a quick terminal or not.
+ pub fn isQuickTerminal(self: *Self) bool {
+ return self.private().quick_terminal;
+ }
+
+ /// Get the currently active surface. See the "active-surface" property.
+ /// This does not ref the value.
+ pub fn getActiveSurface(self: *Self) ?*Surface {
+ const tab = self.getSelectedTab() orelse return null;
+ return tab.getActiveSurface();
+ }
+
+ /// Returns the configuration for this window. The reference count
+ /// is not increased.
+ pub fn getConfig(self: *Self) ?*Config {
+ return self.private().config;
+ }
+
+ /// Get the tab view for this window.
+ pub fn getTabView(self: *Self) *adw.TabView {
+ return self.private().tab_view;
+ }
+
+ /// Get the current window decoration value for this window.
+ pub fn getWindowDecoration(self: *Self) configpkg.WindowDecoration {
+ const priv = self.private();
+ if (priv.window_decoration) |v| return v;
+ if (priv.config) |v| return v.get().@"window-decoration";
+ return .auto;
+ }
+
+ /// Toggle the window decorations for this window.
+ pub fn toggleWindowDecorations(self: *Self) void {
+ const priv = self.private();
+
+ if (priv.window_decoration) |_| {
+ // Unset any previously set window decoration settings
+ self.setWindowDecoration(null);
+ return;
+ }
+
+ const config = if (priv.config) |v| v.get() else return;
+ self.setWindowDecoration(switch (config.@"window-decoration") {
+ // Use auto when the decoration is initially none
+ .none => .auto,
+
+ // Anything non-none to none
+ .auto, .client, .server => .none,
+ });
+ }
+
+ /// Set the window decoration override for this window. If this is null,
+ /// then we'll revert back to the configuration's default.
+ fn setWindowDecoration(
+ self: *Self,
+ new_: ?configpkg.WindowDecoration,
+ ) void {
+ const priv = self.private();
+ priv.window_decoration = new_;
+ self.syncAppearance();
+ }
+
+ /// Get the currently selected tab as a Tab object.
+ fn getSelectedTab(self: *Self) ?*Tab {
+ const priv = self.private();
+ const page = priv.tab_view.getSelectedPage() orelse return null;
+ const child = page.getChild();
+ assert(gobject.ext.isA(child, Tab));
+ return gobject.ext.cast(Tab, child);
+ }
+
+ /// Returns true if this window needs confirmation before quitting.
+ fn getNeedsConfirmQuit(self: *Self) bool {
+ const priv = self.private();
+ const n = priv.tab_view.getNPages();
+ assert(n >= 0);
+
+ for (0..@intCast(n)) |i| {
+ const page = priv.tab_view.getNthPage(@intCast(i));
+ const child = page.getChild();
+ const tab = gobject.ext.cast(Tab, child) orelse {
+ log.warn("unexpected non-Tab child in tab view", .{});
+ continue;
+ };
+ if (tab.getNeedsConfirmQuit()) return true;
+ }
+
+ return false;
+ }
+
+ fn isFullscreen(self: *Window) bool {
+ return self.as(gtk.Window).isFullscreen() != 0;
+ }
+
+ fn isMaximized(self: *Window) bool {
+ return self.as(gtk.Window).isMaximized() != 0;
+ }
+
+ fn getHeaderbarVisible(self: *Self) bool {
+ const priv = self.private();
+
+ // Never display the header bar when CSDs are disabled.
+ const csd_enabled = priv.winproto.clientSideDecorationEnabled();
+ if (!csd_enabled) return false;
+
+ // Never display the header bar as a quick terminal.
+ if (priv.quick_terminal) return false;
+
+ // If we're fullscreen we never show the header bar.
+ if (self.isFullscreen()) return false;
+
+ // The remainder needs a config
+ const config_obj = self.private().config orelse return true;
+ const config = config_obj.get();
+
+ // *Conditionally* disable the header bar when maximized, and
+ // gtk-titlebar-hide-when-maximized is set
+ if (self.isMaximized() and config.@"gtk-titlebar-hide-when-maximized") {
+ return false;
+ }
+
+ return switch (config.@"gtk-titlebar-style") {
+ // If the titlebar style is tabs never show the titlebar.
+ .tabs => false,
+
+ // If the titlebar style is native show the titlebar if configured
+ // to do so.
+ .native => config.@"gtk-titlebar",
+ };
+ }
+
+ fn getTabsAutohide(self: *Self) bool {
+ const priv = self.private();
+ const config = if (priv.config) |v| v.get() else return true;
+
+ return switch (config.@"gtk-titlebar-style") {
+ // If the titlebar style is tabs we cannot autohide.
+ .tabs => false,
+
+ .native => switch (config.@"window-show-tab-bar") {
+ // Auto we always autohide... obviously.
+ .auto => true,
+
+ // Always we never autohide because we always show the tab bar.
+ .always => false,
+
+ // Never we autohide because it doesn't actually matter,
+ // since getTabsVisible will return false.
+ .never => true,
+ },
+ };
+ }
+
+ fn getTabsVisible(self: *Self) bool {
+ const priv = self.private();
+ const config = if (priv.config) |v| v.get() else return true;
+
+ switch (config.@"gtk-titlebar-style") {
+ .tabs => {
+ // *Conditionally* disable the tab bar when maximized, the titlebar
+ // style is tabs, and gtk-titlebar-hide-when-maximized is set.
+ if (self.isMaximized() and config.@"gtk-titlebar-hide-when-maximized") return false;
+
+ // If the titlebar style is tabs the tab bar must always be visible.
+ return true;
+ },
+ .native => {
+ return switch (config.@"window-show-tab-bar") {
+ .always, .auto => true,
+ .never => false,
+ };
+ },
+ }
+ }
+
+ fn getTabsWide(self: *Self) bool {
+ const priv = self.private();
+ const config = if (priv.config) |v| v.get() else return true;
+ return config.@"gtk-wide-tabs";
+ }
+
+ fn getToolbarStyle(self: *Self) adw.ToolbarStyle {
+ const priv = self.private();
+ const config = if (priv.config) |v| v.get() else return .raised;
+ return switch (config.@"gtk-toolbar-style") {
+ .flat => .flat,
+ .raised => .raised,
+ .@"raised-border" => .raised_border,
+ };
+ }
+
+ fn getTitlebarStyle(self: *Self) TitlebarStyle {
+ const priv = self.private();
+ const config = if (priv.config) |v| v.get() else return .native;
+ return config.@"gtk-titlebar-style";
+ }
+
+ fn propConfig(
+ _: *adw.ApplicationWindow,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ const priv = self.private();
+ if (priv.config) |config_obj| {
+ const config = config_obj.get();
+ if (config.@"app-notifications".@"config-reload") {
+ self.addToast(i18n._("Reloaded the configuration"));
+ }
+ }
+
+ self.syncAppearance();
+ }
+
+ fn propIsActive(
+ _: *gtk.Window,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ // Hide quick-terminal if set to autohide
+ if (self.isQuickTerminal()) {
+ if (self.getConfig()) |cfg| {
+ if (cfg.get().@"quick-terminal-autohide" and self.as(gtk.Window).isActive() == 0) {
+ self.toggleVisibility();
+ }
+ }
+ }
+
+ // Don't change urgency if we're not the active window.
+ if (self.as(gtk.Window).isActive() == 0) return;
+
+ self.winproto().setUrgent(false) catch |err| {
+ log.warn(
+ "winproto failed to reset urgency={}",
+ .{err},
+ );
+ };
+ }
+
+ fn propGdkSurfaceDims(
+ _: *gdk.Surface,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ // X11 needs to fix blurring on resize, but winproto implementations
+ // could do anything.
+ self.private().winproto.resizeEvent() catch |err| {
+ log.warn(
+ "winproto resize event failed error={}",
+ .{err},
+ );
+ };
+ }
+
+ fn propFullscreened(
+ _: *adw.ApplicationWindow,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ self.syncAppearance();
+ }
+
+ fn propMaximized(
+ _: *adw.ApplicationWindow,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ self.syncAppearance();
+ }
+
+ fn propMenuActive(
+ button: *gtk.MenuButton,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ // Debian 12 is stuck on GTK 4.8
+ if (!gtk_version.atLeast(4, 10, 0)) return;
+
+ // We only care if we're activating. If we're activating then
+ // we need to check the validity of our menu items.
+ const active = button.getActive() != 0;
+ if (!active) return;
+
+ self.syncActions();
+ }
+
+ fn propQuickTerminal(
+ _: *adw.ApplicationWindow,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ const priv = self.private();
+ if (priv.surface_init) {
+ log.warn("quick terminal property can't be changed after surfaces have been initialized", .{});
+ return;
+ }
+
+ if (priv.quick_terminal) {
+ // Initialize the quick terminal at the app-layer
+ Application.default().winproto().initQuickTerminal(self) catch |err| {
+ log.warn("failed to initialize quick terminal error={}", .{err});
+ return;
+ };
+ }
+ }
+
+ fn propScaleFactor(
+ _: *adw.ApplicationWindow,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ // On some platforms (namely X11) we need to refresh our appearance when
+ // the scale factor changes. In theory this could be more fine-grained as
+ // a full refresh could be expensive, but a) this *should* be rare, and
+ // b) quite noticeable visual bugs would occur if this is not present.
+ self.private().winproto.syncAppearance() catch |err| {
+ log.warn(
+ "failed to sync appearance after scale factor has been updated={}",
+ .{err},
+ );
+ return;
+ };
+ }
+
+ fn closureTitlebarStyleIsTab(
+ _: *Self,
+ value: TitlebarStyle,
+ ) callconv(.c) c_int {
+ return @intFromBool(switch (value) {
+ .native => false,
+ .tabs => true,
+ });
+ }
+
+ fn closureSubtitle(
+ _: *Self,
+ config_: ?*Config,
+ pwd_: ?[*:0]const u8,
+ ) callconv(.c) ?[*:0]const u8 {
+ const config = if (config_) |v| v.get() else return null;
+ return switch (config.@"window-subtitle") {
+ .false => null,
+ .@"working-directory" => pwd: {
+ const pwd = pwd_ orelse return null;
+ break :pwd glib.ext.dupeZ(u8, std.mem.span(pwd));
+ },
+ };
+ }
+
+ //---------------------------------------------------------------
+ // Virtual methods
+
+ fn dispose(self: *Self) callconv(.c) void {
+ const priv = self.private();
+
+ priv.command_palette.set(null);
+
+ if (priv.config) |v| {
+ v.unref();
+ priv.config = null;
+ }
+
+ priv.tab_bindings.setSource(null);
+
+ gtk.Widget.disposeTemplate(
+ self.as(gtk.Widget),
+ getGObjectType(),
+ );
+
+ gobject.Object.virtual_methods.dispose.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ fn finalize(self: *Self) callconv(.c) void {
+ const priv = self.private();
+ priv.tab_bindings.unref();
+ priv.winproto.deinit();
+
+ gobject.Object.virtual_methods.finalize.call(
+ Class.parent,
+ self.as(Parent),
+ );
+ }
+
+ //---------------------------------------------------------------
+ // Signal handlers
+
+ fn windowRealize(_: *gtk.Widget, self: *Window) callconv(.c) void {
+ const app = Application.default();
+
+ // Initialize our window protocol logic
+ if (winprotopkg.Window.init(
+ app.allocator(),
+ app.winproto(),
+ self,
+ )) |wp| {
+ self.private().winproto = wp;
+ } else |err| {
+ log.warn("failed to initialize window protocol error={}", .{err});
+ return;
+ }
+
+ // We need to setup resize notifications on our surface,
+ // which is only available after the window had been realized.
+ if (self.as(gtk.Native).getSurface()) |gdk_surface| {
+ _ = gobject.Object.signals.notify.connect(
+ gdk_surface,
+ *Self,
+ propGdkSurfaceDims,
+ self,
+ .{ .detail = "width" },
+ );
+ _ = gobject.Object.signals.notify.connect(
+ gdk_surface,
+ *Self,
+ propGdkSurfaceDims,
+ self,
+ .{ .detail = "height" },
+ );
+ }
+
+ // When we are realized we always setup our appearance since this
+ // calls some winproto functions.
+ self.syncAppearance();
+ }
+
+ fn btnNewTab(_: *adw.SplitButton, self: *Self) callconv(.c) void {
+ self.performBindingAction(.new_tab);
+ }
+
+ fn tabOverviewCreateTab(
+ _: *adw.TabOverview,
+ self: *Self,
+ ) callconv(.c) *adw.TabPage {
+ return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null, .tab, .none);
+ }
+
+ fn tabOverviewOpen(
+ tab_overview: *adw.TabOverview,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ // We only care about when the tab overview is closed.
+ if (tab_overview.getOpen() != 0) return;
+
+ // On tab overview close, focus is sometimes lost. This is an
+ // upstream issue in libadwaita[1]. When this is resolved we
+ // can put a runtime version check here to avoid this workaround.
+ //
+ // Our workaround is to start a timer after 500ms to refocus
+ // the currently selected tab. We choose 500ms because the adw
+ // animation is 400ms.
+ //
+ // [1]: https://gitlab.gnome.org/GNOME/libadwaita/-/issues/670
+
+ // If we have an old timer remove it
+ const priv = self.private();
+ if (priv.tab_overview_focus_timer) |timer| {
+ _ = glib.Source.remove(timer);
+ }
+
+ // Restart our timer
+ priv.tab_overview_focus_timer = glib.timeoutAdd(
+ 500,
+ tabOverviewFocusTimer,
+ self,
+ );
+ }
+
+ fn tabOverviewFocusTimer(
+ ud: ?*anyopaque,
+ ) callconv(.c) c_int {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
+
+ // Always note our timer is removed
+ self.private().tab_overview_focus_timer = null;
+
+ // Get our currently active surface which should respect the newly
+ // selected tab. Grab focus.
+ const surface = self.getActiveSurface() orelse return 0;
+ surface.grabFocus();
+
+ // Remove the timer
+ return 0;
+ }
+
+ fn windowCloseRequest(
+ _: *gtk.Window,
+ self: *Self,
+ ) callconv(.c) c_int {
+ if (self.getNeedsConfirmQuit()) {
+ // Show a confirmation dialog
+ const dialog: *CloseConfirmationDialog = .new(.window);
+ _ = CloseConfirmationDialog.signals.@"close-request".connect(
+ dialog,
+ *Self,
+ closeConfirmationClose,
+ self,
+ .{},
+ );
+
+ // Show it
+ dialog.present(self.as(gtk.Widget));
+ return @intFromBool(true);
+ }
+
+ self.as(gtk.Window).destroy();
+ return @intFromBool(false);
+ }
+
+ fn closeConfirmationClose(
+ _: *CloseConfirmationDialog,
+ self: *Self,
+ ) callconv(.c) void {
+ self.as(gtk.Window).destroy();
+ }
+
+ fn closeConfirmationCloseTab(
+ _: *CloseConfirmationDialog,
+ page: *adw.TabPage,
+ ) callconv(.c) void {
+ const tab_view = ext.getAncestor(
+ adw.TabView,
+ page.getChild().as(gtk.Widget),
+ ) orelse {
+ log.warn("close confirmation called for non-existent page", .{});
+ return;
+ };
+ tab_view.closePageFinish(page, @intFromBool(true));
+ }
+
+ fn closeConfirmationCancelTab(
+ _: *CloseConfirmationDialog,
+ page: *adw.TabPage,
+ ) callconv(.c) void {
+ const tab_view = ext.getAncestor(
+ adw.TabView,
+ page.getChild().as(gtk.Widget),
+ ) orelse {
+ log.warn("close confirmation called for non-existent page", .{});
+ return;
+ };
+ tab_view.closePageFinish(page, @intFromBool(false));
+ }
+
+ fn tabViewClosePage(
+ _: *adw.TabView,
+ page: *adw.TabPage,
+ self: *Self,
+ ) callconv(.c) c_int {
+ const priv = self.private();
+ const child = page.getChild();
+ const tab = gobject.ext.cast(Tab, child) orelse
+ return @intFromBool(false);
+
+ // If the tab says it doesn't need confirmation then we go ahead
+ // and close immediately.
+ if (!tab.getNeedsConfirmQuit()) {
+ priv.tab_view.closePageFinish(page, @intFromBool(true));
+ return @intFromBool(true);
+ }
+
+ // Show a confirmation dialog
+ const dialog: *CloseConfirmationDialog = .new(.tab);
+ _ = CloseConfirmationDialog.signals.@"close-request".connect(
+ dialog,
+ *adw.TabPage,
+ closeConfirmationCloseTab,
+ page,
+ .{},
+ );
+ _ = CloseConfirmationDialog.signals.cancel.connect(
+ dialog,
+ *adw.TabPage,
+ closeConfirmationCancelTab,
+ page,
+ .{},
+ );
+
+ // Show it
+ dialog.present(child);
+ return @intFromBool(true);
+ }
+
+ fn tabViewSelectedPage(
+ _: *adw.TabView,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ const priv = self.private();
+
+ // Always reset our binding source in case we have no pages.
+ priv.tab_bindings.setSource(null);
+
+ // Get our current page which MUST be a Tab object.
+ const page = priv.tab_view.getSelectedPage() orelse return;
+ const child = page.getChild();
+ assert(gobject.ext.isA(child, Tab));
+
+ // Setup our binding group. This ensures things like the title
+ // are synced from the active tab.
+ priv.tab_bindings.setSource(child.as(gobject.Object));
+
+ // If the tab was previously marked as needing attention
+ // (e.g. due to a bell character), we now unmark that
+ page.setNeedsAttention(@intFromBool(false));
+ }
+
+ fn tabViewPageAttached(
+ _: *adw.TabView,
+ page: *adw.TabPage,
+ _: c_int,
+ self: *Self,
+ ) callconv(.c) void {
+ // Get the attached page which must be a Tab object.
+ const child = page.getChild();
+ const tab = gobject.ext.cast(Tab, child) orelse return;
+
+ // Attach listeners for the tab.
+ _ = Tab.signals.@"close-request".connect(
+ tab,
+ *Self,
+ tabCloseRequest,
+ self,
+ .{},
+ );
+
+ // Attach listeners for the surface.
+ //
+ // Interesting behavior here that was previously undocumented but
+ // I'm going to make it explicit here: we accept all the signals here
+ // (like toggle-fullscreen) regardless of whether the surface or tab
+ // is focused. At the time of writing this we have no API that could
+ // really trigger these that way but its theoretically possible.
+ //
+ // What is DEFINITELY possible is something like OSC52 triggering
+ // a clipboard-write signal on an unfocused tab/surface. We definitely
+ // want to show the user a notification about that but our notification
+ // right now is a toast that doesn't make it clear WHO used the
+ // clipboard. We probably want to change that in the future.
+ //
+ // I'm not sure how desirable all the above is, and we probably
+ // should be thoughtful about future signals here. But all of this
+ // behavior is consistent with macOS and the previous GTK apprt,
+ // but that behavior was all implicit and not documented, so here
+ // I am.
+ if (tab.getSurfaceTree()) |tree| {
+ self.connectSurfaceHandlers(tree);
+ }
+ }
+
+ fn tabViewPageDetached(
+ _: *adw.TabView,
+ page: *adw.TabPage,
+ _: c_int,
+ self: *Self,
+ ) callconv(.c) void {
+ // We need to get the tab to disconnect the signals.
+ const child = page.getChild();
+ const tab = gobject.ext.cast(Tab, child) orelse return;
+ _ = gobject.signalHandlersDisconnectMatched(
+ tab.as(gobject.Object),
+ .{ .data = true },
+ 0,
+ 0,
+ null,
+ null,
+ self,
+ );
+
+ // Remove the tree handlers
+ if (tab.getSurfaceTree()) |tree| {
+ self.disconnectSurfaceHandlers(tree);
+ }
+ }
+
+ fn tabViewCreateWindow(
+ _: *adw.TabView,
+ _: *Self,
+ ) callconv(.c) *adw.TabView {
+ // Create a new window without creating a new tab.
+ const win = gobject.ext.newInstance(
+ Self,
+ .{
+ .application = Application.default(),
+ },
+ );
+
+ // We have to show it otherwise it'll just be hidden.
+ gtk.Window.present(win.as(gtk.Window));
+
+ // Get our tab view
+ return win.private().tab_view;
+ }
+
+ fn tabCloseRequest(
+ tab: *Tab,
+ self: *Self,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const page = priv.tab_view.getPage(tab.as(gtk.Widget));
+ // TODO: connect close page handler to tab to check for confirmation
+ priv.tab_view.closePage(page);
+ }
+
+ fn tabViewNPages(
+ _: *adw.TabView,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ const priv = self.private();
+ if (priv.tab_view.getNPages() == 0) {
+ // If we have no pages left then we want to close window.
+
+ // If the tab overview is open, then we don't close the window
+ // because its a rather abrupt experience. This also fixes an
+ // issue where dragging out the last tab in the tab overview
+ // won't cause Ghostty to exit.
+ if (priv.tab_overview.getOpen() != 0) return;
+
+ self.as(gtk.Window).close();
+ }
+ }
+ fn setupTabMenu(
+ _: *adw.TabView,
+ page: ?*adw.TabPage,
+ self: *Self,
+ ) callconv(.c) void {
+ self.private().context_menu_page = page;
+ }
+
+ fn surfaceClipboardWrite(
+ _: *Surface,
+ clipboard_type: apprt.Clipboard,
+ text: [*:0]const u8,
+ self: *Self,
+ ) callconv(.c) void {
+ // We only toast for the standard clipboard.
+ if (clipboard_type != .standard) return;
+
+ // We only toast if configured to
+ const priv = self.private();
+ const config_obj = priv.config orelse return;
+ const config = config_obj.get();
+ if (!config.@"app-notifications".@"clipboard-copy") {
+ return;
+ }
+
+ if (text[0] != 0)
+ self.addToast(i18n._("Copied to clipboard"))
+ else
+ self.addToast(i18n._("Cleared clipboard"));
+ }
+
+ fn surfaceMenu(
+ _: *Surface,
+ self: *Self,
+ ) callconv(.c) void {
+ self.syncActions();
+ }
+
+ fn surfacePresentRequest(
+ surface: *Surface,
+ self: *Self,
+ ) callconv(.c) void {
+ // Verify that this surface is actually in this window.
+ {
+ const surface_window = ext.getAncestor(
+ Self,
+ surface.as(gtk.Widget),
+ ) orelse {
+ log.warn(
+ "present request called for non-existent surface",
+ .{},
+ );
+ return;
+ };
+ if (surface_window != self) {
+ log.warn(
+ "present request called for surface in different window",
+ .{},
+ );
+ return;
+ }
+ }
+
+ // Get the tab for this surface.
+ const tab = ext.getAncestor(
+ Tab,
+ surface.as(gtk.Widget),
+ ) orelse {
+ log.warn("present request surface not found", .{});
+ return;
+ };
+
+ // Get the page that contains this tab
+ const priv = self.private();
+ const tab_view = priv.tab_view;
+ const page = tab_view.getPage(tab.as(gtk.Widget));
+ tab_view.setSelectedPage(page);
+
+ // Grab focus
+ surface.grabFocus();
+
+ // Bring the window to the front.
+ self.as(gtk.Window).present();
+ }
+
+ fn surfaceToggleFullscreen(
+ surface: *Surface,
+ self: *Self,
+ ) callconv(.c) void {
+ _ = surface;
+ if (self.as(gtk.Window).isFullscreen() != 0) {
+ self.as(gtk.Window).unfullscreen();
+ } else {
+ self.as(gtk.Window).fullscreen();
+ }
+
+ // We react to the changes in the propFullscreen callback
+ }
+
+ fn surfaceToggleMaximize(
+ surface: *Surface,
+ self: *Self,
+ ) callconv(.c) void {
+ _ = surface;
+ if (self.as(gtk.Window).isMaximized() != 0) {
+ self.as(gtk.Window).unmaximize();
+ } else {
+ self.as(gtk.Window).maximize();
+ }
+
+ // We react to the changes in the propMaximized callback
+ }
+
+ fn surfaceInit(
+ surface: *Surface,
+ self: *Self,
+ ) callconv(.c) void {
+ const priv = self.private();
+
+ // Make sure we init only once
+ if (priv.surface_init) return;
+ priv.surface_init = true;
+
+ // Setup our default and minimum size.
+ if (surface.getDefaultSize()) |size| {
+ self.as(gtk.Window).setDefaultSize(
+ @intCast(size.width),
+ @intCast(size.height),
+ );
+ }
+ if (surface.getMinSize()) |size| {
+ self.as(gtk.Widget).setSizeRequest(
+ @intCast(size.width),
+ @intCast(size.height),
+ );
+ }
+ }
+
+ fn tabSplitTreeChanged(
+ _: *SplitTree,
+ old_tree: ?*const Surface.Tree,
+ new_tree: ?*const Surface.Tree,
+ self: *Self,
+ ) callconv(.c) void {
+ if (old_tree) |tree| {
+ self.disconnectSurfaceHandlers(tree);
+ }
+
+ if (new_tree) |tree| {
+ self.connectSurfaceHandlers(tree);
+ }
+ }
+
+ fn actionAbout(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ const name = "Ghostty";
+ const icon = "com.mitchellh.ghostty";
+ const website = "https://ghostty.org";
+
+ if (adw_version.supportsDialogs()) {
+ adw.showAboutDialog(
+ self.as(gtk.Widget),
+ "application-name",
+ name,
+ "developer-name",
+ i18n._("Ghostty Developers"),
+ "application-icon",
+ icon,
+ "version",
+ build_config.version_string.ptr,
+ "issue-url",
+ "https://github.com/ghostty-org/ghostty/issues",
+ "website",
+ website,
+ @as(?*anyopaque, null),
+ );
+ } else {
+ gtk.showAboutDialog(
+ self.as(gtk.Window),
+ "program-name",
+ name,
+ "logo-icon-name",
+ icon,
+ "title",
+ i18n._("About Ghostty"),
+ "version",
+ build_config.version_string.ptr,
+ "website",
+ website,
+ @as(?*anyopaque, null),
+ );
+ }
+ }
+
+ fn actionClose(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ self.as(gtk.Window).close();
+ }
+
+ fn actionCloseTab(
+ _: *gio.SimpleAction,
+ param_: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ const param = param_ orelse {
+ log.warn("win.close-tab called without a parameter", .{});
+ return;
+ };
+
+ var str: ?[*:0]const u8 = null;
+ param.get("&s", &str);
+
+ const mode = std.meta.stringToEnum(
+ input.Binding.Action.CloseTabMode,
+ std.mem.span(
+ str orelse {
+ log.warn("invalid mode provided to win.close-tab", .{});
+ return;
+ },
+ ),
+ ) orelse {
+ log.warn("invalid mode provided to win.close-tab: {s}", .{str.?});
+ return;
+ };
+
+ self.performBindingAction(.{ .close_tab = mode });
+ }
+
+ fn actionNewWindow(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ self.performBindingAction(.new_window);
+ }
+
+ fn actionNewTab(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ self.performBindingAction(.new_tab);
+ }
+
+ fn actionPromptContextTabTitle(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const page = priv.context_menu_page orelse return;
+ const child = page.getChild();
+ const tab = gobject.ext.cast(Tab, child) orelse return;
+ tab.promptTabTitle();
+ }
+
+ fn actionPromptSurfaceTitle(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ self.performBindingAction(.prompt_surface_title);
+ }
+
+ fn actionPromptTabTitle(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ self.performBindingAction(.prompt_tab_title);
+ }
+
+ fn actionSplitRight(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ self.performBindingAction(.{ .new_split = .right });
+ }
+
+ fn actionSplitLeft(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ self.performBindingAction(.{ .new_split = .left });
+ }
+
+ fn actionSplitUp(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ self.performBindingAction(.{ .new_split = .up });
+ }
+
+ fn actionSplitDown(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ self.performBindingAction(.{ .new_split = .down });
+ }
+
+ fn actionCopy(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ self.performBindingAction(.{ .copy_to_clipboard = .mixed });
+ }
+
+ fn actionPaste(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ self.performBindingAction(.paste_from_clipboard);
+ }
+
+ fn actionReset(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ self.performBindingAction(.reset);
+ }
+
+ fn actionClear(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ self.performBindingAction(.clear_screen);
+ }
+
+ fn actionRingBell(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ const priv = self.private();
+ const config = if (priv.config) |v| v.get() else return;
+
+ if (config.@"bell-features".system) system: {
+ const native = self.as(gtk.Native).getSurface() orelse {
+ log.warn("unable to get native surface from window", .{});
+ break :system;
+ };
+ native.beep();
+ }
+
+ if (config.@"bell-features".attention) attention: {
+ // Dont set urgency if the window is already active.
+ if (self.as(gtk.Window).isActive() != 0) break :attention;
+
+ // Request user attention
+ self.winproto().setUrgent(true) catch |err| {
+ log.warn("winproto failed to set urgency={}", .{err});
+ };
+ }
+ }
+
+ /// Toggle the command palette.
+ ///
+ /// TODO: accept the surface that toggled the command palette as a parameter
+ fn toggleCommandPalette(self: *Window) void {
+ const priv = self.private();
+
+ // Get a reference to a command palette. First check the weak reference
+ // that we save to see if we already have one stored. If we don't then
+ // create a new one.
+ const command_palette = priv.command_palette.get() orelse command_palette: {
+ // Create a fresh command palette.
+ const command_palette = CommandPalette.new();
+
+ // Synchronize our config to the command palette's config.
+ _ = gobject.Object.bindProperty(
+ self.as(gobject.Object),
+ "config",
+ command_palette.as(gobject.Object),
+ "config",
+ .{ .sync_create = true },
+ );
+
+ // Listen to the activate signal to know if the user selected an option in
+ // the command palette.
+ _ = CommandPalette.signals.trigger.connect(
+ command_palette,
+ *Window,
+ signalCommandPaletteTrigger,
+ self,
+ .{},
+ );
+
+ // Save a weak reference to the command palette. We use a weak reference to avoid
+ // reference counting cycles that might cause problems later.
+ priv.command_palette.set(command_palette);
+
+ break :command_palette command_palette;
+ };
+ defer command_palette.unref();
+
+ // Tell the command palette to toggle itself. If the dialog gets
+ // presented (instead of hidden) it will be modal over our window.
+ command_palette.toggle(self);
+ }
+
+ // React to a signal from a command palette asking an action to be performed.
+ fn signalCommandPaletteTrigger(_: *CommandPalette, action: *const input.Binding.Action, self: *Self) callconv(.c) void {
+ // If the activation actually has an action, perform it.
+ self.performBindingAction(action.*);
+ }
+
+ /// React to a GTK action requesting that the command palette be toggled.
+ fn actionToggleCommandPalette(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ // TODO: accept the surface that toggled the command palette as a
+ // parameter
+ self.toggleCommandPalette();
+ }
+
+ /// Toggle the Ghostty inspector for the active surface.
+ fn toggleInspector(self: *Self) void {
+ const surface = self.getActiveSurface() orelse return;
+ _ = surface.controlInspector(.toggle);
+ }
+
+ /// React to a GTK action requesting that the Ghostty inspector be toggled.
+ fn actionToggleInspector(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ // TODO: accept the surface that toggled the command palette as a
+ // parameter
+ self.toggleInspector();
+ }
+
+ const C = Common(Self, Private);
+ pub const as = C.as;
+ pub const ref = C.ref;
+ pub const unref = C.unref;
+ const private = C.private;
+
+ pub const Class = extern struct {
+ parent_class: Parent.Class,
+ var parent: *Parent.Class = undefined;
+ pub const Instance = Self;
+
+ fn init(class: *Class) callconv(.c) void {
+ gobject.ext.ensureType(DebugWarning);
+ gobject.ext.ensureType(SplitTree);
+ gobject.ext.ensureType(Surface);
+ gobject.ext.ensureType(Tab);
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 5,
+ .name = "window",
+ }),
+ );
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.@"active-surface".impl,
+ properties.config.impl,
+ properties.debug.impl,
+ properties.@"headerbar-visible".impl,
+ properties.@"quick-terminal".impl,
+ properties.@"tabs-autohide".impl,
+ properties.@"tabs-visible".impl,
+ properties.@"tabs-wide".impl,
+ properties.@"toolbar-style".impl,
+ properties.@"titlebar-style".impl,
+ });
+
+ // Bindings
+ class.bindTemplateChildPrivate("tab_overview", .{});
+ class.bindTemplateChildPrivate("tab_bar", .{});
+ class.bindTemplateChildPrivate("tab_view", .{});
+ class.bindTemplateChildPrivate("toolbar", .{});
+ class.bindTemplateChildPrivate("toast_overlay", .{});
+
+ // Template Callbacks
+ class.bindTemplateCallback("realize", &windowRealize);
+ class.bindTemplateCallback("new_tab", &btnNewTab);
+ class.bindTemplateCallback("overview_create_tab", &tabOverviewCreateTab);
+ class.bindTemplateCallback("overview_notify_open", &tabOverviewOpen);
+ class.bindTemplateCallback("close_request", &windowCloseRequest);
+ class.bindTemplateCallback("close_page", &tabViewClosePage);
+ class.bindTemplateCallback("page_attached", &tabViewPageAttached);
+ class.bindTemplateCallback("page_detached", &tabViewPageDetached);
+ class.bindTemplateCallback("setup_tab_menu", &setupTabMenu);
+ class.bindTemplateCallback("tab_create_window", &tabViewCreateWindow);
+ class.bindTemplateCallback("notify_n_pages", &tabViewNPages);
+ class.bindTemplateCallback("notify_selected_page", &tabViewSelectedPage);
+ class.bindTemplateCallback("notify_config", &propConfig);
+ class.bindTemplateCallback("notify_fullscreened", &propFullscreened);
+ class.bindTemplateCallback("notify_is_active", &propIsActive);
+ class.bindTemplateCallback("notify_maximized", &propMaximized);
+ class.bindTemplateCallback("notify_menu_active", &propMenuActive);
+ class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal);
+ class.bindTemplateCallback("notify_scale_factor", &propScaleFactor);
+ class.bindTemplateCallback("titlebar_style_is_tabs", &closureTitlebarStyleIsTab);
+ class.bindTemplateCallback("computed_subtitle", &closureSubtitle);
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ }
+
+ pub const as = C.Class.as;
+ pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
+ pub const bindTemplateCallback = C.Class.bindTemplateCallback;
+ };
+};