diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/apprt/gtk/class | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/apprt/gtk/class')
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; + }; +}; |
