apprt/gtk-ng: configuration reloading, toasts (#8084)

This brings in configuration reloading and toasts to gtk-ng. 

Config reloading is fairly different in ng than legacy because we rely
on our GObject `Config` class and ref counting more heavily. We rely on
various property bindings and notify signals to propagate configuration
changes out to all subscribers. Previously we manually had to chain this
together.

Toasts are straightforward, with the main difference being that the
window owns its own toasts (surfaces can't trigger them) and triggers
them via signal emission.
This commit is contained in:
Mitchell Hashimoto
2025-07-27 15:20:10 -07:00
committed by GitHub
9 changed files with 277 additions and 90 deletions

View File

@ -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.
///

View File

@ -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.
@ -508,6 +513,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 +537,6 @@ pub const Application = extern struct {
.equalize_splits,
.goto_split,
.open_config,
.reload_config,
.inspector,
.desktop_notification,
.present_terminal,
@ -573,29 +579,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 +645,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 +802,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 +970,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 +1030,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 +1163,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 +1234,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 +1291,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 => {},

View File

@ -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"),
},
);
};

View File

@ -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"),
},
);
};
@ -275,7 +265,10 @@ pub const Surface = extern struct {
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
&.{
apprt.Clipboard,
[*:0]const u8,
},
void,
);
};
@ -1190,6 +1183,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,
@ -2236,7 +2237,7 @@ const Clipboard = struct {
Surface.signals.@"clipboard-write".impl.emit(
self,
null,
.{},
.{ clipboard_type, val.ptr },
null,
);

View File

@ -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"),
},
);
};

View File

@ -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");
@ -68,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"),
},
);
};
@ -122,7 +118,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 +224,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 +273,7 @@ pub const Window = extern struct {
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
self.addToast(i18n._("Reloaded the configuration"));
self.syncAppearance();
}
@ -377,6 +387,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 +575,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);

View File

@ -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();
}
}
};
}

View File

@ -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) {

View File

@ -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