From ccde429bde0c36dc0725cdddd75fc89ed8b111bd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 26 Jul 2025 12:29:07 -0700 Subject: [PATCH 1/5] apprt/gtk-ng: toasts --- src/apprt/gtk-ng/class/surface.zig | 7 +++-- src/apprt/gtk-ng/class/window.zig | 42 +++++++++++++++++++++++++++++- src/apprt/gtk-ng/ui/1.5/window.blp | 11 +++++--- src/apprt/structs.zig | 19 +++++++++++++- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 7d162b4e9..0cefebc63 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -275,7 +275,10 @@ pub const Surface = extern struct { const impl = gobject.ext.defineSignal( name, Self, - &.{}, + &.{ + apprt.Clipboard, + [*:0]const u8, + }, void, ); }; @@ -2236,7 +2239,7 @@ const Clipboard = struct { Surface.signals.@"clipboard-write".impl.emit( self, null, - .{}, + .{ clipboard_type, val.ptr }, null, ); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 875fad9b6..ac5555391 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -8,6 +8,7 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); const i18n = @import("../../../os/main.zig").i18n; +const apprt = @import("../../../apprt.zig"); const input = @import("../../../input.zig"); const CoreSurface = @import("../../../Surface.zig"); const gtk_version = @import("../gtk_version.zig"); @@ -122,7 +123,8 @@ pub const Window = extern struct { config: ?*Config = null, // Template bindings - surface: *Surface = undefined, + surface: *Surface, + toast_overlay: *adw.ToastOverlay, pub var offset: c_int = 0; }; @@ -227,6 +229,18 @@ pub const Window = extern struct { }; } + /// 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: *Window, title: [*:0]const u8) void { + const toast = adw.Toast.new(title); + toast.setTimeout(3); + self.private().toast_overlay.addToast(toast); + } + //--------------------------------------------------------------- // Properties @@ -264,6 +278,7 @@ pub const Window = extern struct { _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { + self.addToast(i18n._("Reloaded the configuration")); self.syncAppearance(); } @@ -377,6 +392,29 @@ pub const Window = extern struct { self.as(gtk.Window).destroy(); } + 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 surfaceCloseRequest( surface: *Surface, scope: *const Surface.CloseScope, @@ -542,9 +580,11 @@ pub const Window = extern struct { // Bindings class.bindTemplateChildPrivate("surface", .{}); + class.bindTemplateChildPrivate("toast_overlay", .{}); // Template Callbacks class.bindTemplateCallback("close_request", &windowCloseRequest); + class.bindTemplateCallback("surface_clipboard_write", &surfaceClipboardWrite); class.bindTemplateCallback("surface_close_request", &surfaceCloseRequest); class.bindTemplateCallback("surface_toggle_fullscreen", &surfaceToggleFullscreen); class.bindTemplateCallback("surface_toggle_maximize", &surfaceToggleMaximize); diff --git a/src/apprt/gtk-ng/ui/1.5/window.blp b/src/apprt/gtk-ng/ui/1.5/window.blp index c8ffdade3..1e2345d95 100644 --- a/src/apprt/gtk-ng/ui/1.5/window.blp +++ b/src/apprt/gtk-ng/ui/1.5/window.blp @@ -43,10 +43,13 @@ template $GhosttyWindow: Adw.ApplicationWindow { visible: bind template.debug; } - $GhosttySurface surface { - close-request => $surface_close_request(); - toggle-fullscreen => $surface_toggle_fullscreen(); - toggle-maximize => $surface_toggle_maximize(); + Adw.ToastOverlay toast_overlay { + $GhosttySurface surface { + close-request => $surface_close_request(); + clipboard-write => $surface_clipboard_write(); + toggle-fullscreen => $surface_toggle_fullscreen(); + toggle-maximize => $surface_toggle_maximize(); + } } }; } diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 1c3b28723..c9948f3ee 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -29,10 +29,27 @@ pub const IMEPos = struct { /// The clipboard type. /// /// If this is changed, you must also update ghostty.h -pub const Clipboard = enum(u2) { +pub const Clipboard = enum(Backing) { standard = 0, // ctrl+c/v selection = 1, primary = 2, + + // Our backing isn't is as small as we can in Zig, but a full + // C int if we're binding to C APIs. + const Backing = switch (build_config.app_runtime) { + .gtk, .@"gtk-ng" => c_int, + else => u2, + }; + + /// Make this a valid gobject if we're in a GTK environment. + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk, .@"gtk-ng" => @import("gobject").ext.defineEnum( + Clipboard, + .{ .name = "GhosttyApprtClipboard" }, + ), + + .none => void, + }; }; pub const ClipboardRequestType = enum(u8) { From 53c7b8922fc6a8927596ae0ea34161febc42ba8a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 27 Jul 2025 13:46:24 -0700 Subject: [PATCH 2/5] apprt/gtk-ng: reload config --- src/apprt/gtk-ng/class/application.zig | 142 ++++++++++++++++++------- src/apprt/gtk-ng/class/surface.zig | 8 ++ 2 files changed, 111 insertions(+), 39 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 3682c5ba6..d27210b5c 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -508,6 +508,8 @@ pub const Application = extern struct { .progress_report => return Action.progressReport(target, value), + .reload_config => try Action.reloadConfig(self, target, value), + .render => Action.render(target), .ring_bell => Action.ringBell(target), @@ -530,7 +532,6 @@ pub const Application = extern struct { .equalize_splits, .goto_split, .open_config, - .reload_config, .inspector, .desktop_notification, .present_terminal, @@ -573,29 +574,6 @@ pub const Application = extern struct { return true; } - /// Reload the configuration for the application and propagate it - /// across the entire application and all terminals. - pub fn reloadConfig(self: *Self) !void { - const alloc = self.allocator(); - - // Read our new config. We can always deinit this because - // we'll clone and store it if libghostty accepts it and - // emits a `config_change` action. - var config = try CoreConfig.load(alloc); - defer config.deinit(); - - // Notify the app that we've updated. - const priv = self.private(); - try priv.core_app.updateConfig(priv.rt_app, &config); - } - - /// Returns the configuration for this application. - /// - /// The reference count is increased. - pub fn getConfig(self: *Self) *Config { - return self.private().config.ref(); - } - /// 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 { @@ -662,6 +640,31 @@ pub const Application = extern struct { } } + //--------------------------------------------------------------- + // 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(); + } + //--------------------------------------------------------------- // Libghostty Callbacks @@ -794,9 +797,10 @@ pub const Application = extern struct { // For action names: // https://docs.gtk.org/gio/type_func.Action.name_is_valid.html const actions = .{ - .{ "quit", actionQuit, null }, .{ "new-window", actionNewWindow, null }, .{ "new-window-command", actionNewWindow, as_variant_type }, + .{ "quit", actionQuit, null }, + .{ "reload-config", actionReloadConfig, null }, }; const action_map = self.as(gio.ActionMap); @@ -961,7 +965,12 @@ pub const Application = extern struct { const priv = self.private(); priv.config_errors_dialog.set(null); - self.reloadConfig() catch |err| { + // 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. @@ -1016,6 +1025,17 @@ pub const Application = extern struct { dialog.present(null); } + 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, @@ -1138,21 +1158,11 @@ const Action = struct { // Wrap our config in a GObject. This will clone it. const alloc = self.allocator(); const config_obj: *Config = try .new(alloc, new_config); - errdefer config_obj.unref(); + defer config_obj.unref(); switch (target) { - // TODO: when we implement surfaces in gtk-ng - .surface => @panic("TODO"), - - .app => { - // Set it on our private - const priv = self.private(); - priv.config.unref(); - priv.config = config_obj; - - // Show our errors if we have any - self.showConfigErrorsDialog(); - }, + .surface => |core| core.rt_surface.surface.setConfig(config_obj), + .app => self.setConfig(config_obj), } } @@ -1219,6 +1229,19 @@ const Action = struct { parent: ?*CoreSurface, ) !void { const win = Window.new(self, parent); + + // 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", + .{}, + ); + + // Show the window gtk.Window.present(win.as(gtk.Window)); } @@ -1263,6 +1286,47 @@ const Action = struct { }; } + /// Reload the configuration for the application and propagate it + /// across the entire application and all terminals. + pub fn reloadConfig( + self: *Application, + target: apprt.Target, + opts: apprt.action.ReloadConfig, + ) !void { + // Tell systemd that reloading has started. + systemd.notify.reloading(); + + // When we exit this function tell systemd that reloading has finished. + defer systemd.notify.ready(); + + // Get our config object. + const config: *Config = config: { + // Soft-reloading applies conditional logic to the existing loaded + // config so we return that as-is (but take a reference). + if (opts.soft) { + break :config self.private().config.ref(); + } + + // Hard reload, load a new config completely. + const alloc = self.allocator(); + var config = try CoreConfig.load(alloc); + defer config.deinit(); + break :config try .new(alloc, &config); + }; + defer config.unref(); + + // Update the proper target. This will trigger a `confige_change` + // apprt action which will propagate the config properly to our + // property system. + switch (target) { + .app => try self.core().updateConfig( + self.rt(), + config.get(), + ), + .surface => |core| try core.updateConfig(config.get()), + } + } + pub fn render(target: apprt.Target) void { switch (target) { .app => {}, diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 0cefebc63..98c470764 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1193,6 +1193,14 @@ pub const Surface = extern struct { return self.private().pwd; } + /// 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); + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, From b011706aad44cde9c99e428dc8d85495becaeac5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 27 Jul 2025 14:12:26 -0700 Subject: [PATCH 3/5] suppressions --- valgrind.supp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/valgrind.supp b/valgrind.supp index 7f48b580a..c80530663 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -353,8 +353,7 @@ match-leak-kinds: possible fun:*alloc fun:FcFontSet* - fun:FcFontSet* - fun:sort_in_thread.isra.0 + ... fun:fc_thread_func fun:g_thread_proxy fun:start_thread From c4de0010232ce8d5127f33322573fb34ab784f84 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 27 Jul 2025 14:27:40 -0700 Subject: [PATCH 4/5] apprt/gtk-ng: avoid reading corrupt memory in event loop --- src/apprt/gtk-ng/class/application.zig | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index d27210b5c..3b4250956 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -361,11 +361,15 @@ pub const Application = extern struct { // // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 const priv = self.private(); - const config = priv.config.get(); - if (config.@"initial-window") switch (config.@"launched-from".?) { - .desktop, .cli => self.as(gio.Application).activate(), - .dbus, .systemd => {}, - }; + { + // We need to scope any config access because once we run our + // event loop, this can change out from underneath us. + const config = priv.config.get(); + if (config.@"initial-window") switch (config.@"launched-from".?) { + .desktop, .cli => self.as(gio.Application).activate(), + .dbus, .systemd => {}, + }; + } // If we are NOT the primary instance, then we never want to run. // This means that another instance of the GTK app is running and @@ -393,6 +397,7 @@ pub const Application = extern struct { // 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. From bf61f29f570208cf6e70e665e4334547c4b18f09 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 27 Jul 2025 15:07:12 -0700 Subject: [PATCH 5/5] apprt/gtk-ng: create the privateObjFieldAccessor helper to unref --- src/apprt/gtk-ng/class.zig | 78 +++++++++++++++++++ .../class/clipboard_confirmation_dialog.zig | 14 +--- src/apprt/gtk-ng/class/surface.zig | 14 +--- .../gtk-ng/class/surface_child_exited.zig | 7 +- src/apprt/gtk-ng/class/window.zig | 7 +- 5 files changed, 84 insertions(+), 36 deletions(-) diff --git a/src/apprt/gtk-ng/class.zig b/src/apprt/gtk-ng/class.zig index 427789560..dc024c5cf 100644 --- a/src/apprt/gtk-ng/class.zig +++ b/src/apprt/gtk-ng/class.zig @@ -46,6 +46,84 @@ pub fn Common( } }).private else {}; + /// A helper that can be used to create a property that reads and + /// writes a private boxed gobject field type. + /// + /// Reading the property will result in allocating a pointer and + /// setting it will free the previous pointer. + /// + /// The object class (Self) must still free the private field + /// in finalize! + pub fn privateBoxedFieldAccessor( + comptime name: []const u8, + ) gobject.ext.Accessor( + Self, + @FieldType(Private.?, name), + ) { + return .{ + .getter = &struct { + fn get(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + @field(private(self), name), + ); + } + }.get, + .setter = &struct { + fn set(self: *Self, value: *const gobject.Value) void { + const priv = private(self); + if (@field(priv, name)) |v| { + glib.ext.destroy(v); + } + + const T = @TypeOf(@field(priv, name)); + @field( + priv, + name, + ) = gobject.ext.Value.dup(value, T); + } + }.set, + }; + } + + /// A helper that can be used to create a property that reads and + /// writes a private field gobject field type (reference counted). + /// + /// Reading the property will result in taking a reference to the + /// value and writing the property will unref the previous value. + /// + /// The object class (Self) must still free the private field + /// in finalize! + pub fn privateObjFieldAccessor( + comptime name: []const u8, + ) gobject.ext.Accessor( + Self, + @FieldType(Private.?, name), + ) { + return .{ + .getter = &struct { + fn get(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + @field(private(self), name), + ); + } + }.get, + .setter = &struct { + fn set(self: *Self, value: *const gobject.Value) void { + const priv = private(self); + if (@field(priv, name)) |v| v.unref(); + + const T = @TypeOf(@field(priv, name)); + @field( + priv, + name, + ) = gobject.ext.Value.dup(value, T); + } + }.set, + }; + } + /// A helper that can be used to create a property that reads and /// writes a private `?[:0]const u8` field type. /// diff --git a/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig index 7769d8935..cb9a27444 100644 --- a/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig +++ b/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig @@ -59,12 +59,7 @@ pub const ClipboardConfirmationDialog = extern struct { .{ .nick = "Request", .blurb = "The clipboard request.", - .accessor = gobject.ext.privateFieldAccessor( - Self, - Private, - &Private.offset, - "request", - ), + .accessor = C.privateBoxedFieldAccessor("request"), }, ); }; @@ -78,12 +73,7 @@ pub const ClipboardConfirmationDialog = extern struct { .{ .nick = "Clipboard Contents", .blurb = "The clipboard contents being read/written.", - .accessor = gobject.ext.privateFieldAccessor( - Self, - Private, - &Private.offset, - "clipboard_contents", - ), + .accessor = C.privateObjFieldAccessor("clipboard_contents"), }, ); }; diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 98c470764..bb432cf6f 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -50,12 +50,7 @@ pub const Surface = extern struct { .{ .nick = "Config", .blurb = "The configuration that this surface is using.", - .accessor = gobject.ext.privateFieldAccessor( - Self, - Private, - &Private.offset, - "config", - ), + .accessor = C.privateObjFieldAccessor("config"), }, ); }; @@ -89,12 +84,7 @@ pub const Surface = extern struct { .{ .nick = "Desired Font Size", .blurb = "The desired font size, only affects initialization.", - .accessor = gobject.ext.privateFieldAccessor( - Self, - Private, - &Private.offset, - "font_size_request", - ), + .accessor = C.privateBoxedFieldAccessor("font_size_request"), }, ); }; diff --git a/src/apprt/gtk-ng/class/surface_child_exited.zig b/src/apprt/gtk-ng/class/surface_child_exited.zig index 693425c09..3bf29285f 100644 --- a/src/apprt/gtk-ng/class/surface_child_exited.zig +++ b/src/apprt/gtk-ng/class/surface_child_exited.zig @@ -42,12 +42,7 @@ const SurfaceChildExitedBanner = extern struct { .{ .nick = "Data", .blurb = "The child exit data.", - .accessor = gobject.ext.privateFieldAccessor( - Self, - Private, - &Private.offset, - "data", - ), + .accessor = C.privateBoxedFieldAccessor("data"), }, ); }; diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index ac5555391..d822bfd49 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -69,12 +69,7 @@ pub const Window = extern struct { .{ .nick = "Config", .blurb = "The configuration that this surface is using.", - .accessor = gobject.ext.privateFieldAccessor( - Self, - Private, - &Private.offset, - "config", - ), + .accessor = C.privateObjFieldAccessor("config"), }, ); };